diff --git a/pom.xml b/pom.xml index e53dc82acf6..48380fc5cfd 100644 --- a/pom.xml +++ b/pom.xml @@ -152,6 +152,20 @@ + + com.spatial4j + spatial4j + 0.2 + compile + + + + com.vividsolutions + jts + 1.12 + compile + + log4j log4j diff --git a/src/main/assemblies/common-bin.xml b/src/main/assemblies/common-bin.xml index 8ed5f3fc222..dd2f2003aee 100644 --- a/src/main/assemblies/common-bin.xml +++ b/src/main/assemblies/common-bin.xml @@ -8,6 +8,8 @@ log4j:log4j net.java.dev.jna:jna org.xerial.snappy:snappy-java + com.spatial4j:spatial4j + com.vividsolutions:jts diff --git a/src/main/java/org/elasticsearch/common/geo/GeoJSONShapeParser.java b/src/main/java/org/elasticsearch/common/geo/GeoJSONShapeParser.java new file mode 100644 index 00000000000..484a6c76fa2 --- /dev/null +++ b/src/main/java/org/elasticsearch/common/geo/GeoJSONShapeParser.java @@ -0,0 +1,186 @@ +package org.elasticsearch.common.geo; + +import com.spatial4j.core.shape.Shape; +import com.spatial4j.core.shape.jts.JtsGeometry; +import com.spatial4j.core.shape.jts.JtsPoint; +import com.spatial4j.core.shape.simple.RectangleImpl; +import com.vividsolutions.jts.geom.*; +import org.elasticsearch.ElasticSearchParseException; +import org.elasticsearch.common.xcontent.XContentParser; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +/** + * Parsers which supports reading {@link Shape}s in GeoJSON format from a given + * {@link XContentParser}. + * + * An example of the format used for polygons: + * + * { + * "type": "Polygon", + * "coordinates": [ + * [ [100.0, 0.0], [101.0, 0.0], [101.0, 1.0], + * [100.0, 1.0], [100.0, 0.0] ] + * ] + * } + * + * Note, currently MultiPolygon and GeometryCollections are not supported + */ +public class GeoJSONShapeParser { + + private static final GeometryFactory GEOMETRY_FACTORY = new GeometryFactory(); + + private GeoJSONShapeParser() { + } + + /** + * Parses the current object from the given {@link XContentParser}, creating + * the {@link Shape} representation + * + * @param parser Parser that will be read from + * @return Shape representation of the geojson defined Shape + * @throws IOException Thrown if an error occurs while reading from the XContentParser + */ + public static Shape parse(XContentParser parser) throws IOException { + if (parser.currentToken() != XContentParser.Token.START_OBJECT) { + throw new ElasticSearchParseException("Shape must be an object consisting of type and coordinates"); + } + + String shapeType = null; + CoordinateNode node = null; + + XContentParser.Token token; + while ((token = parser.nextToken()) != XContentParser.Token.END_OBJECT) { + if (token == XContentParser.Token.FIELD_NAME) { + String fieldName = parser.currentName(); + + if ("type".equals(fieldName)) { + token = parser.nextToken(); + shapeType = parser.text().toLowerCase(Locale.ENGLISH); + if (shapeType == null) { + throw new ElasticSearchParseException("Unknown Shape type [" + parser.text() + "]"); + } + } else if ("coordinates".equals(fieldName)) { + token = parser.nextToken(); + node = parseCoordinates(parser); + } + } + } + + if (shapeType == null) { + throw new ElasticSearchParseException("Shape type not included"); + } else if (node == null) { + throw new ElasticSearchParseException("Coordinates not included"); + } + + return buildShape(shapeType, node); + } + + /** + * Recursive method which parses the arrays of coordinates used to define Shapes + * + * @param parser Parser that will be read from + * @return CoordinateNode representing the start of the coordinate tree + * @throws IOException Thrown if an error occurs while reading from the XContentParser + */ + private static CoordinateNode parseCoordinates(XContentParser parser) throws IOException { + XContentParser.Token token = parser.nextToken(); + + // Base case + if (token != XContentParser.Token.START_ARRAY) { + double lon = parser.doubleValue(); + token = parser.nextToken(); + double lat = parser.doubleValue(); + token = parser.nextToken(); + return new CoordinateNode(new Coordinate(lon, lat)); + } + + List nodes = new ArrayList(); + while (token != XContentParser.Token.END_ARRAY) { + nodes.add(parseCoordinates(parser)); + token = parser.nextToken(); + } + + return new CoordinateNode(nodes); + } + + /** + * Builds the actual {@link Shape} with the given shape type from the tree + * of coordinates + * + * @param shapeType Type of Shape to be built + * @param node Root node of the coordinate tree + * @return Shape built from the coordinates + */ + private static Shape buildShape(String shapeType, CoordinateNode node) { + if ("point".equals(shapeType)) { + return new JtsPoint(GEOMETRY_FACTORY.createPoint(node.coordinate)); + } else if ("linestring".equals(shapeType)) { + return new JtsGeometry(GEOMETRY_FACTORY.createLineString(toCoordinates(node))); + } else if ("polygon".equals(shapeType)) { + LinearRing shell = GEOMETRY_FACTORY.createLinearRing(toCoordinates(node.children.get(0))); + LinearRing[] holes = null; + if (node.children.size() > 1) { + holes = new LinearRing[node.children.size() - 1]; + for (int i = 0; i < node.children.size() - 1; i++) { + holes[i] = GEOMETRY_FACTORY.createLinearRing(toCoordinates(node.children.get(i + 1))); + } + } + return new JtsGeometry(GEOMETRY_FACTORY.createPolygon(shell, holes)); + } else if ("multipoint".equals(shapeType)) { + return new JtsGeometry(GEOMETRY_FACTORY.createMultiPoint(toCoordinates(node))); + } else if ("envelope".equals(shapeType)) { + Coordinate[] coordinates = toCoordinates(node); + return new RectangleImpl(coordinates[0].x, coordinates[1].x, coordinates[1].y, coordinates[0].y); + } + + throw new UnsupportedOperationException("ShapeType [" + shapeType + "] not supported"); + } + + /** + * Converts the children of the given CoordinateNode into an array of + * {@link Coordinate}. + * + * @param node CoordinateNode whose children will be converted + * @return Coordinate array with the values taken from the children of the Node + */ + private static Coordinate[] toCoordinates(CoordinateNode node) { + Coordinate[] coordinates = new Coordinate[node.children.size()]; + for (int i = 0; i < node.children.size(); i++) { + coordinates[i] = node.children.get(i).coordinate; + } + return coordinates; + } + + /** + * Node used to represent a tree of coordinates. + * + * Can either be a leaf node consisting of a Coordinate, or a parent with children + */ + private static class CoordinateNode { + + private Coordinate coordinate; + private List children; + + /** + * Creates a new leaf CoordinateNode + * + * @param coordinate Coordinate for the Node + */ + private CoordinateNode(Coordinate coordinate) { + this.coordinate = coordinate; + } + + /** + * Creates a new parent CoordinateNode + * + * @param children Children of the Node + */ + private CoordinateNode(List children) { + this.children = children; + } + } +} diff --git a/src/main/java/org/elasticsearch/common/geo/GeoJSONShapeSerializer.java b/src/main/java/org/elasticsearch/common/geo/GeoJSONShapeSerializer.java new file mode 100644 index 00000000000..c20ddde60a6 --- /dev/null +++ b/src/main/java/org/elasticsearch/common/geo/GeoJSONShapeSerializer.java @@ -0,0 +1,179 @@ +package org.elasticsearch.common.geo; + +import com.spatial4j.core.shape.*; +import com.spatial4j.core.shape.jts.JtsGeometry; +import com.vividsolutions.jts.geom.*; +import com.vividsolutions.jts.geom.Point; +import org.elasticsearch.ElasticSearchIllegalArgumentException; +import org.elasticsearch.common.xcontent.XContentBuilder; + +import java.io.IOException; + +/** + * Serializes {@link Shape} instances into GeoJSON format + * + * Example of the format used for points: + * + * { "type": "Point", "coordinates": [100.0, 0.0] } + */ +public class GeoJSONShapeSerializer { + + private GeoJSONShapeSerializer() { + } + + /** + * Serializes the given {@link Shape} as GeoJSON format into the given + * {@link XContentBuilder} + * + * @param shape Shape that will be serialized + * @param builder XContentBuilder it will be serialized to + * @throws IOException Thrown if an error occurs while writing to the XContentBuilder + */ + public static void serialize(Shape shape, XContentBuilder builder) throws IOException { + if (shape instanceof JtsGeometry) { + Geometry geometry = ((JtsGeometry) shape).geo; + if (geometry instanceof Point) { + serializePoint((Point) geometry, builder); + } else if (geometry instanceof LineString) { + serializeLineString((LineString) geometry, builder); + } else if (geometry instanceof Polygon) { + serializePolygon((Polygon) geometry, builder); + } else if (geometry instanceof MultiPoint) { + serializeMultiPoint((MultiPoint) geometry, builder); + } else { + throw new ElasticSearchIllegalArgumentException("Geometry type [" + geometry.getGeometryType() + "] not supported"); + } + } else if (shape instanceof com.spatial4j.core.shape.Point) { + serializePoint((com.spatial4j.core.shape.Point) shape, builder); + } else if (shape instanceof Rectangle) { + serializeRectangle((Rectangle) shape, builder); + } else { + throw new ElasticSearchIllegalArgumentException("Shape type [" + shape.getClass().getSimpleName() + "] not supported"); + } + } + + /** + * Serializes the given {@link Rectangle} + * + * @param rectangle Rectangle that will be serialized + * @param builder XContentBuilder it will be serialized to + * @throws IOException Thrown if an error occurs while writing to the XContentBuilder + */ + private static void serializeRectangle(Rectangle rectangle, XContentBuilder builder) throws IOException { + builder.field("type", "Envelope") + .startArray("coordinates") + .startArray().value(rectangle.getMinX()).value(rectangle.getMaxY()).endArray() + .startArray().value(rectangle.getMaxX()).value(rectangle.getMinY()).endArray() + .endArray(); + } + + /** + * Serializes the given {@link Point} + * + * @param point Point that will be serialized + * @param builder XContentBuilder it will be serialized to + * @throws IOException Thrown if an error occurs while writing to the XContentBuilder + */ + private static void serializePoint(Point point, XContentBuilder builder) throws IOException { + builder.field("type", "Point") + .startArray("coordinates") + .value(point.getX()).value(point.getY()) + .endArray(); + } + + /** + * Serializes the given {@link com.spatial4j.core.shape.Point} + * + * @param point Point that will be serialized + * @param builder XContentBuilder it will be serialized to + * @throws IOException Thrown if an error occurs while writing to the XContentBuilder + */ + private static void serializePoint(com.spatial4j.core.shape.Point point, XContentBuilder builder) throws IOException { + builder.field("type", "Point") + .startArray("coordinates") + .value(point.getX()).value(point.getY()) + .endArray(); + } + + /** + * Serializes the given {@link LineString} + * + * @param lineString LineString that will be serialized + * @param builder XContentBuilder it will be serialized to + * @throws IOException Thrown if an error occurs while writing to the XContentBuilder + */ + private static void serializeLineString(LineString lineString, XContentBuilder builder) throws IOException { + builder.field("type", "LineString") + .startArray("coordinates"); + + for (Coordinate coordinate : lineString.getCoordinates()) { + serializeCoordinate(coordinate, builder); + } + + builder.endArray(); + } + + /** + * Serializes the given {@link Polygon} + * + * @param polygon Polygon that will be serialized + * @param builder XContentBuilder it will be serialized to + * @throws IOException Thrown if an error occurs while writing to the XContentBuilder + */ + private static void serializePolygon(Polygon polygon, XContentBuilder builder) throws IOException { + builder.field("type", "Polygon") + .startArray("coordinates"); + + builder.startArray(); // start outer ring + + for (Coordinate coordinate : polygon.getExteriorRing().getCoordinates()) { + serializeCoordinate(coordinate, builder); + } + + builder.endArray(); // end outer ring + + for (int i = 0; i < polygon.getNumInteriorRing(); i++) { + LineString interiorRing = polygon.getInteriorRingN(i); + + builder.startArray(); + + for (Coordinate coordinate : interiorRing.getCoordinates()) { + serializeCoordinate(coordinate, builder); + } + + builder.endArray(); + } + + + builder.endArray(); + } + + /** + * Serializes the given {@link MultiPoint} + * + * @param multiPoint MulitPoint that will be serialized + * @param builder XContentBuilder it will be serialized to + * @throws IOException Thrown if an error occurs while writing to the XContentBuilder + */ + private static void serializeMultiPoint(MultiPoint multiPoint, XContentBuilder builder) throws IOException { + builder.field("type", "MultiPoint") + .startArray("coordinates"); + + for (Coordinate coordinate : multiPoint.getCoordinates()) { + serializeCoordinate(coordinate, builder); + } + + builder.endArray(); + } + + /** + * Serializes the given {@link Coordinate} + * + * @param coordinate Coordinate that will be serialized + * @param builder XContentBuilder it will be serialized to + * @throws IOException Thrown if an error occurs while writing to the XContentBuilder + */ + private static void serializeCoordinate(Coordinate coordinate, XContentBuilder builder) throws IOException { + builder.startArray().value(coordinate.x).value(coordinate.y).endArray(); + } +} diff --git a/src/main/java/org/elasticsearch/common/geo/ShapeBuilder.java b/src/main/java/org/elasticsearch/common/geo/ShapeBuilder.java new file mode 100644 index 00000000000..fa573612061 --- /dev/null +++ b/src/main/java/org/elasticsearch/common/geo/ShapeBuilder.java @@ -0,0 +1,172 @@ +package org.elasticsearch.common.geo; + +import com.spatial4j.core.shape.Point; +import com.spatial4j.core.shape.Rectangle; +import com.spatial4j.core.shape.Shape; +import com.spatial4j.core.shape.jts.JtsGeometry; +import com.spatial4j.core.shape.jts.JtsPoint; +import com.spatial4j.core.shape.simple.PointImpl; +import com.spatial4j.core.shape.simple.RectangleImpl; +import com.vividsolutions.jts.geom.*; + +import java.util.ArrayList; +import java.util.List; + +/** + * Utility class for building {@link Shape} instances like {@link Point}, + * {@link Rectangle} and Polygons. + */ +public class ShapeBuilder { + + private static final GeometryFactory GEOMETRY_FACTORY = new GeometryFactory(); + + private ShapeBuilder() { + } + + /** + * Creates a new {@link Point} + * + * @param lon Longitude of point + * @param lat Latitude of point + * @return Point with the latitude and longitude + */ + public static Point newPoint(double lon, double lat) { + return new PointImpl(lon, lat); + } + + /** + * Creates a new {@link RectangleBuilder} to build a {@link Rectangle} + * + * @return RectangleBuilder instance + */ + public static RectangleBuilder newRectangle() { + return new RectangleBuilder(); + } + + /** + * Creates a new {@link PolygonBuilder} to build a Polygon + * + * @return PolygonBuilder instance + */ + public static PolygonBuilder newPolygon() { + return new PolygonBuilder(); + } + + /** + * Converts the given Shape into the JTS {@link Geometry} representation. + * If the Shape already uses a Geometry, that is returned. + * + * @param shape Shape to convert + * @return Geometry representation of the Shape + */ + public static Geometry toJTSGeometry(Shape shape) { + if (shape instanceof JtsGeometry) { + return ((JtsGeometry) shape).geo; + } else if (shape instanceof JtsPoint) { + return ((JtsPoint) shape).getJtsPoint(); + } else if (shape instanceof Rectangle) { + Rectangle rectangle = (Rectangle) shape; + + if (rectangle.getCrossesDateLine()) { + throw new IllegalArgumentException("Cannot convert Rectangles that cross the dateline into JTS Geometrys"); + } + + return newPolygon().point(rectangle.getMinX(), rectangle.getMaxY()) + .point(rectangle.getMaxX(), rectangle.getMaxY()) + .point(rectangle.getMaxX(), rectangle.getMinY()) + .point(rectangle.getMinX(), rectangle.getMinY()) + .point(rectangle.getMinX(), rectangle.getMaxY()).toPolygon(); + } else if (shape instanceof Point) { + Point point = (Point) shape; + return GEOMETRY_FACTORY.createPoint(new Coordinate(point.getX(), point.getY())); + } + + throw new IllegalArgumentException("Shape type [" + shape.getClass().getSimpleName() + "] not supported"); + } + + /** + * Builder for creating a {@link Rectangle} instance + */ + public static class RectangleBuilder { + + private Point topLeft; + private Point bottomRight; + + /** + * Sets the top left point of the Rectangle + * + * @param lon Longitude of the top left point + * @param lat Latitude of the top left point + * @return this + */ + public RectangleBuilder topLeft(double lon, double lat) { + this.topLeft = new PointImpl(lon, lat); + return this; + } + + /** + * Sets the bottom right point of the Rectangle + * + * @param lon Longitude of the bottom right point + * @param lat Latitude of the bottom right point + * @return this + */ + public RectangleBuilder bottomRight(double lon, double lat) { + this.bottomRight = new PointImpl(lon, lat); + return this; + } + + /** + * Builds the {@link Rectangle} instance + * + * @return Built Rectangle + */ + public Rectangle build() { + return new RectangleImpl(topLeft.getX(), bottomRight.getX(), bottomRight.getY(), topLeft.getY()); + } + } + + /** + * Builder for creating a {@link Shape} instance of a Polygon + */ + public static class PolygonBuilder { + + private final List points = new ArrayList(); + + /** + * Adds a point to the Polygon + * + * @param lon Longitude of the point + * @param lat Latitude of the point + * @return this + */ + public PolygonBuilder point(double lon, double lat) { + points.add(new PointImpl(lon, lat)); + return this; + } + + /** + * Builds a {@link Shape} instance representing the polygon + * + * @return Built polygon + */ + public Shape build() { + return new JtsGeometry(toPolygon()); + } + + /** + * Creates the raw {@link Polygon} + * + * @return Built polygon + */ + public Polygon toPolygon() { + Coordinate[] coordinates = new Coordinate[points.size()]; + for (int i = 0; i < points.size(); i++) { + coordinates[i] = new Coordinate(points.get(i).getX(), points.get(i).getY()); + } + + LinearRing ring = GEOMETRY_FACTORY.createLinearRing(coordinates); + return GEOMETRY_FACTORY.createPolygon(ring, null); + } + } +} diff --git a/src/main/java/org/elasticsearch/common/geo/ShapeRelation.java b/src/main/java/org/elasticsearch/common/geo/ShapeRelation.java new file mode 100644 index 00000000000..1aaeb4f1d70 --- /dev/null +++ b/src/main/java/org/elasticsearch/common/geo/ShapeRelation.java @@ -0,0 +1,34 @@ +package org.elasticsearch.common.geo; + +import java.util.Locale; + +/** + * Enum representing the relationship between a Query / Filter Shape and indexed Shapes + * that will be used to determine if a Document should be matched or not + */ +public enum ShapeRelation { + + INTERSECTS("intersects"), + DISJOINT("disjoint"), + CONTAINS("contains"); + + private final String relationName; + + ShapeRelation(String relationName) { + this.relationName = relationName; + } + + public static ShapeRelation getRelationByName(String name) { + name = name.toLowerCase(Locale.ENGLISH); + for (ShapeRelation relation : ShapeRelation.values()) { + if (relation.relationName.equals(name)) { + return relation; + } + } + return null; + } + + public String getRelationName() { + return relationName; + } +} diff --git a/src/main/java/org/elasticsearch/common/lucene/spatial/SpatialStrategy.java b/src/main/java/org/elasticsearch/common/lucene/spatial/SpatialStrategy.java new file mode 100644 index 00000000000..a2c90834ed7 --- /dev/null +++ b/src/main/java/org/elasticsearch/common/lucene/spatial/SpatialStrategy.java @@ -0,0 +1,188 @@ +package org.elasticsearch.common.lucene.spatial; + +import com.spatial4j.core.shape.Shape; +import org.apache.lucene.document.Field; +import org.apache.lucene.document.Fieldable; +import org.apache.lucene.search.Filter; +import org.apache.lucene.search.Query; +import org.elasticsearch.common.geo.ShapeRelation; +import org.elasticsearch.common.lucene.spatial.prefix.NodeTokenStream; +import org.elasticsearch.common.lucene.spatial.prefix.tree.Node; +import org.elasticsearch.common.lucene.spatial.prefix.tree.SpatialPrefixTree; +import org.elasticsearch.index.cache.filter.FilterCache; +import org.elasticsearch.index.mapper.FieldMapper; + +import java.util.List; + +/** + * Abstraction of the logic used to index and filter Shapes. + */ +public abstract class SpatialStrategy { + + private final FieldMapper.Names fieldName; + private final double distanceErrorPct; + private final SpatialPrefixTree prefixTree; + + private ThreadLocal nodeTokenStream = new ThreadLocal() { + + @Override + protected NodeTokenStream initialValue() { + return new NodeTokenStream(); + } + }; + + /** + * Creates a new SpatialStrategy that will index and Filter using the + * given field + * + * @param fieldName Name of the field that the Strategy will index in and Filter + * @param prefixTree SpatialPrefixTree that will be used to represent Shapes + * @param distanceErrorPct Distance Error Percentage used to guide the + * SpatialPrefixTree on how precise it should be + */ + protected SpatialStrategy(FieldMapper.Names fieldName, SpatialPrefixTree prefixTree, double distanceErrorPct) { + this.fieldName = fieldName; + this.prefixTree = prefixTree; + this.distanceErrorPct = distanceErrorPct; + } + + /** + * Converts the given Shape into its indexable format. Implementations + * should not store the Shape value as well. + * + * @param shape Shape to convert ints its indexable format + * @return Fieldable for indexing the Shape + */ + public Fieldable createField(Shape shape) { + int detailLevel = prefixTree.getMaxLevelForPrecision(shape, distanceErrorPct); + List nodes = prefixTree.getNodes(shape, detailLevel, true); + NodeTokenStream tokenStream = nodeTokenStream.get(); + tokenStream.setNodes(nodes); + return new Field(fieldName.indexName(), tokenStream); + } + + /** + * Creates a Filter that will find all indexed Shapes that relate to the + * given Shape + * + * @param shape Shape the indexed shapes will relate to + * @param relation Nature of the relation + * @return Filter for finding the related shapes + */ + public Filter createFilter(Shape shape, ShapeRelation relation) { + switch (relation) { + case INTERSECTS: + return createIntersectsFilter(shape); + case CONTAINS: + return createContainsFilter(shape); + case DISJOINT: + return createDisjointFilter(shape); + default: + throw new UnsupportedOperationException("Shape Relation [" + relation.getRelationName() + "] not currently supported"); + } + } + + /** + * Creates a Query that will find all indexed Shapes that relate to the + * given Shape + * + * @param shape Shape the indexed shapes will relate to + * @param relation Nature of the relation + * @return Query for finding the related shapes + */ + public Query createQuery(Shape shape, ShapeRelation relation) { + switch (relation) { + case INTERSECTS: + return createIntersectsQuery(shape); + case CONTAINS: + return createContainsQuery(shape); + case DISJOINT: + return createDisjointQuery(shape); + default: + throw new UnsupportedOperationException("Shape Relation [" + relation.getRelationName() + "] not currently supported"); + } + } + + /** + * Creates a Filter that will find all indexed Shapes that intersect with + * the given Shape + * + * @param shape Shape to find the intersection Shapes of + * @return Filter finding the intersecting indexed Shapes + */ + public abstract Filter createIntersectsFilter(Shape shape); + + /** + * Creates a Query that will find all indexed Shapes that intersect with + * the given Shape + * + * @param shape Shape to find the intersection Shapes of + * @return Query finding the intersecting indexed Shapes + */ + public abstract Query createIntersectsQuery(Shape shape); + + /** + * Creates a Filter that will find all indexed Shapes that are disjoint + * to the given Shape + * + * @param shape Shape to find the disjoint Shapes of + * @return Filter for finding the disjoint indexed Shapes + */ + public abstract Filter createDisjointFilter(Shape shape); + + /** + * Creates a Query that will find all indexed Shapes that are disjoint + * to the given Shape + * + * @param shape Shape to find the disjoint Shapes of + * @return Query for finding the disjoint indexed Shapes + */ + public abstract Query createDisjointQuery(Shape shape); + + /** + * Creates a Filter that will find all indexed Shapes that are properly + * contained within the given Shape (the indexed Shapes will not have + * any area outside of the given Shape). + * + * @param shape Shape to find the contained Shapes of + * @return Filter for finding the contained indexed Shapes + */ + public abstract Filter createContainsFilter(Shape shape); + + /** + * Creates a Query that will find all indexed Shapes that are properly + * contained within the given Shape (the indexed Shapes will not have + * any area outside of the given Shape). + * + * @param shape Shape to find the contained Shapes of + * @return Query for finding the contained indexed Shapes + */ + public abstract Query createContainsQuery(Shape shape); + + /** + * Returns the name of the field this Strategy applies to + * + * @return Name of the field the Strategy applies to + */ + public FieldMapper.Names getFieldName() { + return fieldName; + } + + /** + * Returns the distance error percentage for this Strategy + * + * @return Distance error percentage for the Strategy + */ + public double getDistanceErrorPct() { + return distanceErrorPct; + } + + /** + * Returns the {@link SpatialPrefixTree} used by this Strategy + * + * @return SpatialPrefixTree used by the Strategy + */ + public SpatialPrefixTree getPrefixTree() { + return prefixTree; + } +} diff --git a/src/main/java/org/elasticsearch/common/lucene/spatial/prefix/NodeTokenStream.java b/src/main/java/org/elasticsearch/common/lucene/spatial/prefix/NodeTokenStream.java new file mode 100644 index 00000000000..cdc8dba1354 --- /dev/null +++ b/src/main/java/org/elasticsearch/common/lucene/spatial/prefix/NodeTokenStream.java @@ -0,0 +1,58 @@ +package org.elasticsearch.common.lucene.spatial.prefix; + +import org.apache.lucene.analysis.TokenStream; +import org.apache.lucene.analysis.tokenattributes.CharTermAttribute; +import org.elasticsearch.common.lucene.spatial.prefix.tree.Node; + +import java.io.IOException; +import java.util.Iterator; +import java.util.List; + +/** + * Custom {@link TokenStream} used to convert a list of {@link Node} representing + * a Shape, into indexable terms. + */ +public final class NodeTokenStream extends TokenStream { + + private final CharTermAttribute termAtt = addAttribute(CharTermAttribute.class); + + private List nodes; + private Iterator iterator; + private CharSequence nextTokenStringNeedingLeaf = null; + + @Override + public final boolean incrementToken() throws IOException { + clearAttributes(); + if (nextTokenStringNeedingLeaf != null) { + termAtt.append(nextTokenStringNeedingLeaf); + termAtt.append((char) Node.LEAF_BYTE); + nextTokenStringNeedingLeaf = null; + return true; + } + if (iterator.hasNext()) { + Node cell = iterator.next(); + CharSequence token = cell.getTokenString(); + termAtt.append(token); + if (cell.isLeaf()) { + nextTokenStringNeedingLeaf = token; + } + return true; + } + return false; + } + + @Override + public void reset() throws IOException { + iterator = nodes.iterator(); + nextTokenStringNeedingLeaf = null; + } + + /** + * Sets the Nodes that will be converted into their indexable form + * + * @param nodes Nodes to be converted + */ + public void setNodes(List nodes) { + this.nodes = nodes; + } +} diff --git a/src/main/java/org/elasticsearch/common/lucene/spatial/prefix/TermQueryPrefixTreeStrategy.java b/src/main/java/org/elasticsearch/common/lucene/spatial/prefix/TermQueryPrefixTreeStrategy.java new file mode 100644 index 00000000000..70878c7ea22 --- /dev/null +++ b/src/main/java/org/elasticsearch/common/lucene/spatial/prefix/TermQueryPrefixTreeStrategy.java @@ -0,0 +1,149 @@ +package org.elasticsearch.common.lucene.spatial.prefix; + +import com.spatial4j.core.shape.Shape; +import com.spatial4j.core.shape.jts.JtsGeometry; +import com.vividsolutions.jts.geom.Geometry; +import com.vividsolutions.jts.operation.buffer.BufferOp; +import com.vividsolutions.jts.operation.buffer.BufferParameters; +import org.apache.lucene.index.Term; +import org.apache.lucene.search.*; +import org.elasticsearch.common.lucene.search.OrFilter; +import org.elasticsearch.common.lucene.search.TermFilter; +import org.elasticsearch.common.lucene.search.XBooleanFilter; +import org.elasticsearch.common.lucene.spatial.SpatialStrategy; +import org.elasticsearch.common.lucene.spatial.prefix.tree.Node; +import org.elasticsearch.common.lucene.spatial.prefix.tree.SpatialPrefixTree; +import org.elasticsearch.common.geo.ShapeBuilder; +import org.elasticsearch.index.cache.filter.FilterCache; +import org.elasticsearch.index.mapper.FieldMapper; + +import java.util.ArrayList; +import java.util.List; + +/** + * Implementation of {@link SpatialStrategy} that uses TermQuerys / TermFilters + * to query and filter for Shapes related to other Shapes. + */ +public class TermQueryPrefixTreeStrategy extends SpatialStrategy { + + private static final double CONTAINS_BUFFER_DISTANCE = 0.5; + private static final BufferParameters BUFFER_PARAMETERS = new BufferParameters(3, BufferParameters.CAP_SQUARE); + + /** + * Creates a new TermQueryPrefixTreeStrategy + * + * @param fieldName Name of the field the Strategy applies to + * @param prefixTree SpatialPrefixTree that will be used to represent Shapes + * @param distanceErrorPct Distance Error Percentage used to guide the + * SpatialPrefixTree on how precise it should be + */ + public TermQueryPrefixTreeStrategy(FieldMapper.Names fieldName, SpatialPrefixTree prefixTree, double distanceErrorPct) { + super(fieldName, prefixTree, distanceErrorPct); + } + + /** + * {@inheritDoc} + */ + @Override + public Filter createIntersectsFilter(Shape shape) { + int detailLevel = getPrefixTree().getMaxLevelForPrecision(shape, getDistanceErrorPct()); + List nodes = getPrefixTree().getNodes(shape, detailLevel, false); + + Term[] nodeTerms = new Term[nodes.size()]; + for (int i = 0; i < nodes.size(); i++) { + nodeTerms[i] = getFieldName().createIndexNameTerm(nodes.get(i).getTokenString()); + } + return new XTermsFilter(nodeTerms); + } + + /** + * {@inheritDoc} + */ + @Override + public Query createIntersectsQuery(Shape shape) { + int detailLevel = getPrefixTree().getMaxLevelForPrecision(shape, getDistanceErrorPct()); + List nodes = getPrefixTree().getNodes(shape, detailLevel, false); + + BooleanQuery query = new BooleanQuery(); + for (Node node : nodes) { + query.add(new TermQuery(getFieldName().createIndexNameTerm(node.getTokenString())), + BooleanClause.Occur.SHOULD); + } + + return new ConstantScoreQuery(query); + } + + /** + * {@inheritDoc} + */ + @Override + public Filter createDisjointFilter(Shape shape) { + int detailLevel = getPrefixTree().getMaxLevelForPrecision(shape, getDistanceErrorPct()); + List nodes = getPrefixTree().getNodes(shape, detailLevel, false); + + XBooleanFilter filter = new XBooleanFilter(); + for (Node node : nodes) { + filter.addNot(new TermFilter(getFieldName().createIndexNameTerm(node.getTokenString()))); + } + + return filter; + } + + /** + * {@inheritDoc} + */ + @Override + public Query createDisjointQuery(Shape shape) { + int detailLevel = getPrefixTree().getMaxLevelForPrecision(shape, getDistanceErrorPct()); + List nodes = getPrefixTree().getNodes(shape, detailLevel, false); + + BooleanQuery query = new BooleanQuery(); + query.add(new MatchAllDocsQuery(), BooleanClause.Occur.SHOULD); + for (Node node : nodes) { + query.add(new TermQuery(getFieldName().createIndexNameTerm(node.getTokenString())), + BooleanClause.Occur.MUST_NOT); + } + + return new ConstantScoreQuery(query); + } + + /** + * {@inheritDoc} + */ + @Override + public Filter createContainsFilter(Shape shape) { + Filter intersectsFilter = createIntersectsFilter(shape); + + Geometry shapeGeometry = ShapeBuilder.toJTSGeometry(shape); + // TODO: Need some way to detect if having the buffer is going to push the shape over the dateline + // and throw an error in this instance + Geometry buffer = BufferOp.bufferOp(shapeGeometry, CONTAINS_BUFFER_DISTANCE, BUFFER_PARAMETERS); + Shape bufferedShape = new JtsGeometry(buffer.difference(shapeGeometry)); + Filter bufferedFilter = createIntersectsFilter(bufferedShape); + + XBooleanFilter filter = new XBooleanFilter(); + filter.addShould(intersectsFilter); + filter.addNot(bufferedFilter); + + return filter; + } + + /** + * {@inheritDoc} + */ + @Override + public Query createContainsQuery(Shape shape) { + Query intersectsQuery = createIntersectsQuery(shape); + + Geometry shapeGeometry = ShapeBuilder.toJTSGeometry(shape); + Geometry buffer = BufferOp.bufferOp(shapeGeometry, CONTAINS_BUFFER_DISTANCE, BUFFER_PARAMETERS); + Shape bufferedShape = new JtsGeometry(buffer.difference(shapeGeometry)); + Query bufferedQuery = createIntersectsQuery(bufferedShape); + + BooleanQuery query = new BooleanQuery(); + query.add(intersectsQuery, BooleanClause.Occur.SHOULD); + query.add(bufferedQuery, BooleanClause.Occur.MUST_NOT); + + return new ConstantScoreQuery(query); + } +} diff --git a/src/main/java/org/elasticsearch/common/lucene/spatial/prefix/tree/GeohashPrefixTree.java b/src/main/java/org/elasticsearch/common/lucene/spatial/prefix/tree/GeohashPrefixTree.java new file mode 100644 index 00000000000..3bf0019f6e8 --- /dev/null +++ b/src/main/java/org/elasticsearch/common/lucene/spatial/prefix/tree/GeohashPrefixTree.java @@ -0,0 +1,134 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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.elasticsearch.common.lucene.spatial.prefix.tree; + +import com.spatial4j.core.context.SpatialContext; +import com.spatial4j.core.shape.Point; +import com.spatial4j.core.shape.Rectangle; +import com.spatial4j.core.shape.Shape; +import com.spatial4j.core.util.GeohashUtils; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + + +/** + * A SpatialPrefixGrid based on Geohashes. Uses {@link GeohashUtils} to do all the geohash work. + */ +public class GeohashPrefixTree extends SpatialPrefixTree { + + public GeohashPrefixTree(SpatialContext ctx, int maxLevels) { + super(ctx, maxLevels); + Rectangle bounds = ctx.getWorldBounds(); + if (bounds.getMinX() != -180) + throw new IllegalArgumentException("Geohash only supports lat-lon world bounds. Got "+bounds); + int MAXP = getMaxLevelsPossible(); + if (maxLevels <= 0 || maxLevels > MAXP) + throw new IllegalArgumentException("maxLen must be [1-"+MAXP+"] but got "+ maxLevels); + } + + /** Any more than this and there's no point (double lat & lon are the same). */ + public static int getMaxLevelsPossible() { + return GeohashUtils.MAX_PRECISION; + } + + @Override + public int getLevelForDistance(double dist) { + final int level = GeohashUtils.lookupHashLenForWidthHeight(dist, dist); + return Math.max(Math.min(level, maxLevels), 1); + } + + @Override + public Node getNode(Point p, int level) { + return new GhCell(GeohashUtils.encodeLatLon(p.getY(), p.getX(), level));//args are lat,lon (y,x) + } + + @Override + public Node getNode(String token) { + return new GhCell(token); + } + + @Override + public Node getNode(byte[] bytes, int offset, int len) { + return new GhCell(bytes, offset, len); + } + + @Override + public List getNodes(Shape shape, int detailLevel, boolean inclParents) { + return shape instanceof Point ? super.getNodesAltPoint((Point) shape, detailLevel, inclParents) : + super.getNodes(shape, detailLevel, inclParents); + } + + class GhCell extends Node { + GhCell(String token) { + super(GeohashPrefixTree.this, token); + } + + GhCell(byte[] bytes, int off, int len) { + super(GeohashPrefixTree.this, bytes, off, len); + } + + @Override + public void reset(byte[] bytes, int off, int len) { + super.reset(bytes, off, len); + shape = null; + } + + @Override + public Collection getSubCells() { + String[] hashes = GeohashUtils.getSubGeohashes(getGeohash());//sorted + List cells = new ArrayList(hashes.length); + for (String hash : hashes) { + cells.add(new GhCell(hash)); + } + return cells; + } + + @Override + public int getSubCellsSize() { + return 32;//8x4 + } + + @Override + public Node getSubCell(Point p) { + return GeohashPrefixTree.this.getNode(p,getLevel()+1);//not performant! + } + + private Shape shape;//cache + + @Override + public Shape getShape() { + if (shape == null) { + shape = GeohashUtils.decodeBoundary(getGeohash(), ctx); + } + return shape; + } + + @Override + public Point getCenter() { + return GeohashUtils.decode(getGeohash(), ctx); + } + + private String getGeohash() { + return getTokenString(); + } + + }//class GhCell + +} diff --git a/src/main/java/org/elasticsearch/common/lucene/spatial/prefix/tree/Node.java b/src/main/java/org/elasticsearch/common/lucene/spatial/prefix/tree/Node.java new file mode 100644 index 00000000000..c22acf6d5b2 --- /dev/null +++ b/src/main/java/org/elasticsearch/common/lucene/spatial/prefix/tree/Node.java @@ -0,0 +1,212 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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.elasticsearch.common.lucene.spatial.prefix.tree; + +import com.spatial4j.core.shape.Point; +import com.spatial4j.core.shape.Shape; +import com.spatial4j.core.shape.SpatialRelation; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; + +/** + * Represents a grid cell. These are not necessarily threadsafe, although new Cell("") (world cell) must be. + */ +public abstract class Node implements Comparable { + public static final byte LEAF_BYTE = '+';//NOTE: must sort before letters & numbers + + /* + Holds a byte[] and/or String representation of the cell. Both are lazy constructed from the other. + Neither contains the trailing leaf byte. + */ + private byte[] bytes; + private int b_off; + private int b_len; + + private String token;//this is the only part of equality + + protected SpatialRelation shapeRel;//set in getSubCells(filter), and via setLeaf(). + private SpatialPrefixTree spatialPrefixTree; + + protected Node(SpatialPrefixTree spatialPrefixTree, String token) { + this.spatialPrefixTree = spatialPrefixTree; + this.token = token; + if (token.length() > 0 && token.charAt(token.length() - 1) == (char) LEAF_BYTE) { + this.token = token.substring(0, token.length() - 1); + setLeaf(); + } + + if (getLevel() == 0) + getShape();//ensure any lazy instantiation completes to make this threadsafe + } + + protected Node(SpatialPrefixTree spatialPrefixTree, byte[] bytes, int off, int len) { + this.spatialPrefixTree = spatialPrefixTree; + this.bytes = bytes; + this.b_off = off; + this.b_len = len; + b_fixLeaf(); + } + + public void reset(byte[] bytes, int off, int len) { + assert getLevel() != 0; + token = null; + shapeRel = null; + this.bytes = bytes; + this.b_off = off; + this.b_len = len; + b_fixLeaf(); + } + + private void b_fixLeaf() { + if (bytes[b_off + b_len - 1] == LEAF_BYTE) { + b_len--; + setLeaf(); + } else if (getLevel() == spatialPrefixTree.getMaxLevels()) { + setLeaf(); + } + } + + public SpatialRelation getShapeRel() { + return shapeRel; + } + + public boolean isLeaf() { + return shapeRel == SpatialRelation.WITHIN; + } + + public void setLeaf() { + assert getLevel() != 0; + shapeRel = SpatialRelation.WITHIN; + } + + /** + * Note: doesn't contain a trailing leaf byte. + */ + public String getTokenString() { + if (token == null) { + token = new String(bytes, b_off, b_len, SpatialPrefixTree.UTF8); + } + return token; + } + + /** + * Note: doesn't contain a trailing leaf byte. + */ + public byte[] getTokenBytes() { + if (bytes != null) { + if (b_off != 0 || b_len != bytes.length) { + throw new IllegalStateException("Not supported if byte[] needs to be recreated."); + } + } else { + bytes = token.getBytes(SpatialPrefixTree.UTF8); + b_off = 0; + b_len = bytes.length; + } + return bytes; + } + + public int getLevel() { + return token != null ? token.length() : b_len; + } + + //TODO add getParent() and update some algorithms to use this? + //public Cell getParent(); + + /** + * Like {@link #getSubCells()} but with the results filtered by a shape. If that shape is a {@link com.spatial4j.core.shape.Point} then it + * must call {@link #getSubCell(com.spatial4j.core.shape.Point)}; + * Precondition: Never called when getLevel() == maxLevel. + * + * @param shapeFilter an optional filter for the returned cells. + * @return A set of cells (no dups), sorted. Not Modifiable. + */ + public Collection getSubCells(Shape shapeFilter) { + //Note: Higher-performing subclasses might override to consider the shape filter to generate fewer cells. + if (shapeFilter instanceof Point) { + return Collections.singleton(getSubCell((Point) shapeFilter)); + } + Collection cells = getSubCells(); + + if (shapeFilter == null) { + return cells; + } + List copy = new ArrayList(cells.size());//copy since cells contractually isn't modifiable + for (Node cell : cells) { + SpatialRelation rel = cell.getShape().relate(shapeFilter, spatialPrefixTree.ctx); + if (rel == SpatialRelation.DISJOINT) + continue; + cell.shapeRel = rel; + copy.add(cell); + } + cells = copy; + return cells; + } + + /** + * Performant implementations are expected to implement this efficiently by considering the current + * cell's boundary. + * Precondition: Never called when getLevel() == maxLevel. + * Precondition: this.getShape().relate(p) != DISJOINT. + */ + public abstract Node getSubCell(Point p); + + //TODO Cell getSubCell(byte b) + + /** + * Gets the cells at the next grid cell level that cover this cell. + * Precondition: Never called when getLevel() == maxLevel. + * + * @return A set of cells (no dups), sorted. Not Modifiable. + */ + protected abstract Collection getSubCells(); + + /** + * {@link #getSubCells()}.size() -- usually a constant. Should be >=2 + */ + public abstract int getSubCellsSize(); + + public abstract Shape getShape(); + + public Point getCenter() { + return getShape().getCenter(); + } + + @Override + public int compareTo(Node o) { + return getTokenString().compareTo(o.getTokenString()); + } + + @Override + public boolean equals(Object obj) { + return !(obj == null || !(obj instanceof Node)) && getTokenString().equals(((Node) obj).getTokenString()); + } + + @Override + public int hashCode() { + return getTokenString().hashCode(); + } + + @Override + public String toString() { + return getTokenString() + (isLeaf() ? (char) LEAF_BYTE : ""); + } + +} diff --git a/src/main/java/org/elasticsearch/common/lucene/spatial/prefix/tree/QuadPrefixTree.java b/src/main/java/org/elasticsearch/common/lucene/spatial/prefix/tree/QuadPrefixTree.java new file mode 100644 index 00000000000..6dc6dcb3367 --- /dev/null +++ b/src/main/java/org/elasticsearch/common/lucene/spatial/prefix/tree/QuadPrefixTree.java @@ -0,0 +1,284 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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.elasticsearch.common.lucene.spatial.prefix.tree; + +import com.spatial4j.core.context.SpatialContext; +import com.spatial4j.core.shape.Point; +import com.spatial4j.core.shape.Rectangle; +import com.spatial4j.core.shape.Shape; +import com.spatial4j.core.shape.SpatialRelation; +import com.spatial4j.core.shape.simple.PointImpl; + +import java.text.NumberFormat; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + + +public class QuadPrefixTree extends SpatialPrefixTree { + + public static final int MAX_LEVELS_POSSIBLE = 50;//not really sure how big this should be + + public static final int DEFAULT_MAX_LEVELS = 12; + private final double xmin; + private final double xmax; + private final double ymin; + private final double ymax; + private final double xmid; + private final double ymid; + + private final double gridW; + public final double gridH; + + final double[] levelW; + final double[] levelH; + final int[] levelS; // side + final int[] levelN; // number + + public QuadPrefixTree( + SpatialContext ctx, Rectangle bounds, int maxLevels) { + super(ctx, maxLevels); + this.xmin = bounds.getMinX(); + this.xmax = bounds.getMaxX(); + this.ymin = bounds.getMinY(); + this.ymax = bounds.getMaxY(); + + levelW = new double[maxLevels]; + levelH = new double[maxLevels]; + levelS = new int[maxLevels]; + levelN = new int[maxLevels]; + + gridW = xmax - xmin; + gridH = ymax - ymin; + this.xmid = xmin + gridW/2.0; + this.ymid = ymin + gridH/2.0; + levelW[0] = gridW/2.0; + levelH[0] = gridH/2.0; + levelS[0] = 2; + levelN[0] = 4; + + for (int i = 1; i < levelW.length; i++) { + levelW[i] = levelW[i - 1] / 2.0; + levelH[i] = levelH[i - 1] / 2.0; + levelS[i] = levelS[i - 1] * 2; + levelN[i] = levelN[i - 1] * 4; + } + } + + public QuadPrefixTree(SpatialContext ctx) { + this(ctx, DEFAULT_MAX_LEVELS); + } + + public QuadPrefixTree( + SpatialContext ctx, int maxLevels) { + this(ctx, ctx.getWorldBounds(), maxLevels); + } + + public void printInfo() { + NumberFormat nf = NumberFormat.getNumberInstance(); + nf.setMaximumFractionDigits(5); + nf.setMinimumFractionDigits(5); + nf.setMinimumIntegerDigits(3); + + for (int i = 0; i < maxLevels; i++) { + System.out.println(i + "]\t" + nf.format(levelW[i]) + "\t" + nf.format(levelH[i]) + "\t" + + levelS[i] + "\t" + (levelS[i] * levelS[i])); + } + } + + @Override + public int getLevelForDistance(double dist) { + for (int i = 1; i < maxLevels; i++) { + //note: level[i] is actually a lookup for level i+1 + if(dist > levelW[i] || dist > levelH[i]) { + return i; + } + } + return maxLevels; + } + + @Override + public Node getNode(Point p, int level) { + List cells = new ArrayList(1); + build(xmid, ymid, 0, cells, new StringBuilder(), new PointImpl(p.getX(),p.getY()), level); + return cells.get(0);//note cells could be longer if p on edge + } + + @Override + public Node getNode(String token) { + return new QuadCell(token); + } + + @Override + public Node getNode(byte[] bytes, int offset, int len) { + return new QuadCell(bytes, offset, len); + } + + @Override //for performance + public List getNodes(Shape shape, int detailLevel, boolean inclParents) { + if (shape instanceof Point) + return super.getNodesAltPoint((Point) shape, detailLevel, inclParents); + else + return super.getNodes(shape, detailLevel, inclParents); + } + + private void build( + double x, + double y, + int level, + List matches, + StringBuilder str, + Shape shape, + int maxLevel) { + assert str.length() == level; + double w = levelW[level] / 2; + double h = levelH[level] / 2; + + // Z-Order + // http://en.wikipedia.org/wiki/Z-order_%28curve%29 + checkBattenberg('A', x - w, y + h, level, matches, str, shape, maxLevel); + checkBattenberg('B', x + w, y + h, level, matches, str, shape, maxLevel); + checkBattenberg('C', x - w, y - h, level, matches, str, shape, maxLevel); + checkBattenberg('D', x + w, y - h, level, matches, str, shape, maxLevel); + + // possibly consider hilbert curve + // http://en.wikipedia.org/wiki/Hilbert_curve + // http://blog.notdot.net/2009/11/Damn-Cool-Algorithms-Spatial-indexing-with-Quadtrees-and-Hilbert-Curves + // if we actually use the range property in the query, this could be useful + } + + private void checkBattenberg( + char c, + double cx, + double cy, + int level, + List matches, + StringBuilder str, + Shape shape, + int maxLevel) { + assert str.length() == level; + double w = levelW[level] / 2; + double h = levelH[level] / 2; + + int strlen = str.length(); + Rectangle rectangle = ctx.makeRect(cx - w, cx + w, cy - h, cy + h); + SpatialRelation v = shape.relate(rectangle, ctx); + if (SpatialRelation.CONTAINS == v) { + str.append(c); + //str.append(SpatialPrefixGrid.COVER); + matches.add(new QuadCell(str.toString(),v.transpose())); + } else if (SpatialRelation.DISJOINT == v) { + // nothing + } else { // SpatialRelation.WITHIN, SpatialRelation.INTERSECTS + str.append(c); + + int nextLevel = level+1; + if (nextLevel >= maxLevel) { + //str.append(SpatialPrefixGrid.INTERSECTS); + matches.add(new QuadCell(str.toString(),v.transpose())); + } else { + build(cx, cy, nextLevel, matches, str, shape, maxLevel); + } + } + str.setLength(strlen); + } + + class QuadCell extends Node { + + public QuadCell(String token) { + super(QuadPrefixTree.this, token); + } + + public QuadCell(String token, SpatialRelation shapeRel) { + super(QuadPrefixTree.this, token); + this.shapeRel = shapeRel; + } + + QuadCell(byte[] bytes, int off, int len) { + super(QuadPrefixTree.this, bytes, off, len); + } + + @Override + public void reset(byte[] bytes, int off, int len) { + super.reset(bytes, off, len); + shape = null; + } + + @Override + public Collection getSubCells() { + List cells = new ArrayList(4); + cells.add(new QuadCell(getTokenString()+"A")); + cells.add(new QuadCell(getTokenString()+"B")); + cells.add(new QuadCell(getTokenString()+"C")); + cells.add(new QuadCell(getTokenString()+"D")); + return cells; + } + + @Override + public int getSubCellsSize() { + return 4; + } + + @Override + public Node getSubCell(Point p) { + return QuadPrefixTree.this.getNode(p,getLevel()+1);//not performant! + } + + private Shape shape;//cache + + @Override + public Shape getShape() { + if (shape == null) + shape = makeShape(); + return shape; + } + + private Rectangle makeShape() { + String token = getTokenString(); + double xmin = QuadPrefixTree.this.xmin; + double ymin = QuadPrefixTree.this.ymin; + + for (int i = 0; i < token.length(); i++) { + char c = token.charAt(i); + if ('A' == c || 'a' == c) { + ymin += levelH[i]; + } else if ('B' == c || 'b' == c) { + xmin += levelW[i]; + ymin += levelH[i]; + } else if ('C' == c || 'c' == c) { + // nothing really + } + else if('D' == c || 'd' == c) { + xmin += levelW[i]; + } else { + throw new RuntimeException("unexpected char: " + c); + } + } + int len = token.length(); + double width, height; + if (len > 0) { + width = levelW[len-1]; + height = levelH[len-1]; + } else { + width = gridW; + height = gridH; + } + return ctx.makeRect(xmin, xmin + width, ymin, ymin + height); + } + }//QuadCell +} diff --git a/src/main/java/org/elasticsearch/common/lucene/spatial/prefix/tree/SpatialPrefixTree.java b/src/main/java/org/elasticsearch/common/lucene/spatial/prefix/tree/SpatialPrefixTree.java new file mode 100644 index 00000000000..d72718a1c57 --- /dev/null +++ b/src/main/java/org/elasticsearch/common/lucene/spatial/prefix/tree/SpatialPrefixTree.java @@ -0,0 +1,246 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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.elasticsearch.common.lucene.spatial.prefix.tree; + +import com.spatial4j.core.context.SpatialContext; +import com.spatial4j.core.shape.Point; +import com.spatial4j.core.shape.Shape; + +import java.nio.charset.Charset; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; + +/** + * A Spatial Prefix Tree, or Trie, which decomposes shapes into prefixed strings at variable lengths corresponding to + * variable precision. Each string corresponds to a spatial region. + * + * Implementations of this class should be thread-safe and immutable once initialized. + */ +public abstract class SpatialPrefixTree { + + protected static final Charset UTF8 = Charset.forName("UTF-8"); + + protected final int maxLevels; + + protected final SpatialContext ctx; + + public SpatialPrefixTree(SpatialContext ctx, int maxLevels) { + assert maxLevels > 0; + this.ctx = ctx; + this.maxLevels = maxLevels; + } + + public SpatialContext getSpatialContext() { + return ctx; + } + + public int getMaxLevels() { + return maxLevels; + } + + @Override + public String toString() { + return getClass().getSimpleName() + "(maxLevels:" + maxLevels + ",ctx:" + ctx + ")"; + } + + /** + * See {@link com.spatial4j.core.query.SpatialArgs#getDistPrecision()}. + * A grid level looked up via {@link #getLevelForDistance(double)} is returned. + * + * @param shape + * @param precision 0-0.5 + * @return 1-maxLevels + */ + public int getMaxLevelForPrecision(Shape shape, double precision) { + if (precision < 0 || precision > 0.5) { + throw new IllegalArgumentException("Precision " + precision + " must be between [0-0.5]"); + } + if (precision == 0 || shape instanceof Point) { + return maxLevels; + } + double bboxArea = shape.getBoundingBox().getArea(); + if (bboxArea == 0) { + return maxLevels; + } + double avgSideLenFromCenter = Math.sqrt(bboxArea) / 2; + return getLevelForDistance(avgSideLenFromCenter * precision); + } + + /** + * Returns the level of the smallest grid size with a side length that is greater or equal to the provided + * distance. + * + * @param dist >= 0 + * @return level [1-maxLevels] + */ + public abstract int getLevelForDistance(double dist); + + //TODO double getDistanceForLevel(int level) + + private transient Node worldNode;//cached + + /** + * Returns the level 0 cell which encompasses all spatial data. Equivalent to {@link #getNode(String)} with "". + * This cell is threadsafe, just like a spatial prefix grid is, although cells aren't + * generally threadsafe. + * TODO rename to getTopCell or is this fine? + */ + public Node getWorldNode() { + if (worldNode == null) { + worldNode = getNode(""); + } + return worldNode; + } + + /** + * The cell for the specified token. The empty string should be equal to {@link #getWorldNode()}. + * Precondition: Never called when token length > maxLevel. + */ + public abstract Node getNode(String token); + + public abstract Node getNode(byte[] bytes, int offset, int len); + + public final Node getNode(byte[] bytes, int offset, int len, Node target) { + if (target == null) { + return getNode(bytes, offset, len); + } + + target.reset(bytes, offset, len); + return target; + } + + protected Node getNode(Point p, int level) { + return getNodes(p, level, false).get(0); + } + + /** + * Gets the intersecting & including cells for the specified shape, without exceeding detail level. + * The result is a set of cells (no dups), sorted. Unmodifiable. + *

+ * This implementation checks if shape is a Point and if so uses an implementation that + * recursively calls {@link Node#getSubCell(com.spatial4j.core.shape.Point)}. Cell subclasses + * ideally implement that method with a quick implementation, otherwise, subclasses should + * override this method to invoke {@link #getNodesAltPoint(com.spatial4j.core.shape.Point, int, boolean)}. + * TODO consider another approach returning an iterator -- won't build up all cells in memory. + */ + public List getNodes(Shape shape, int detailLevel, boolean inclParents) { + if (detailLevel > maxLevels) { + throw new IllegalArgumentException("detailLevel > maxLevels"); + } + + List cells; + if (shape instanceof Point) { + //optimized point algorithm + final int initialCapacity = inclParents ? 1 + detailLevel : 1; + cells = new ArrayList(initialCapacity); + recursiveGetNodes(getWorldNode(), (Point) shape, detailLevel, true, cells); + assert cells.size() == initialCapacity; + } else { + cells = new ArrayList(inclParents ? 1024 : 512); + recursiveGetNodes(getWorldNode(), shape, detailLevel, inclParents, cells); + } + if (inclParents) { + Node c = cells.remove(0);//remove getWorldNode() + assert c.getLevel() == 0; + } + return cells; + } + + private void recursiveGetNodes(Node node, Shape shape, int detailLevel, boolean inclParents, + Collection result) { + if (node.isLeaf()) {//cell is within shape + result.add(node); + return; + } + final Collection subCells = node.getSubCells(shape); + if (node.getLevel() == detailLevel - 1) { + if (subCells.size() < node.getSubCellsSize()) { + if (inclParents) + result.add(node); + for (Node subCell : subCells) { + subCell.setLeaf(); + } + result.addAll(subCells); + } else {//a bottom level (i.e. detail level) optimization where all boxes intersect, so use parent cell. + node.setLeaf(); + result.add(node); + } + } else { + if (inclParents) { + result.add(node); + } + for (Node subCell : subCells) { + recursiveGetNodes(subCell, shape, detailLevel, inclParents, result);//tail call + } + } + } + + private void recursiveGetNodes(Node node, Point point, int detailLevel, boolean inclParents, + Collection result) { + if (inclParents) { + result.add(node); + } + final Node pCell = node.getSubCell(point); + if (node.getLevel() == detailLevel - 1) { + pCell.setLeaf(); + result.add(pCell); + } else { + recursiveGetNodes(pCell, point, detailLevel, inclParents, result);//tail call + } + } + + /** + * Subclasses might override {@link #getNodes(com.spatial4j.core.shape.Shape, int, boolean)} + * and check if the argument is a shape and if so, delegate + * to this implementation, which calls {@link #getNode(com.spatial4j.core.shape.Point, int)} and + * then calls {@link #getNode(String)} repeatedly if inclParents is true. + */ + protected final List getNodesAltPoint(Point p, int detailLevel, boolean inclParents) { + Node cell = getNode(p, detailLevel); + if (!inclParents) { + return Collections.singletonList(cell); + } + + String endToken = cell.getTokenString(); + assert endToken.length() == detailLevel; + List cells = new ArrayList(detailLevel); + for (int i = 1; i < detailLevel; i++) { + cells.add(getNode(endToken.substring(0, i))); + } + cells.add(cell); + return cells; + } + + /** + * Will add the trailing leaf byte for leaves. This isn't particularly efficient. + */ + public static List nodesToTokenStrings(Collection nodes) { + List tokens = new ArrayList((nodes.size())); + for (Node node : nodes) { + final String token = node.getTokenString(); + if (node.isLeaf()) { + tokens.add(token + (char) Node.LEAF_BYTE); + } else { + tokens.add(token); + } + } + return tokens; + } +} diff --git a/src/main/java/org/elasticsearch/index/mapper/DocumentMapperParser.java b/src/main/java/org/elasticsearch/index/mapper/DocumentMapperParser.java index 11a998f1d0b..6b303c320f2 100644 --- a/src/main/java/org/elasticsearch/index/mapper/DocumentMapperParser.java +++ b/src/main/java/org/elasticsearch/index/mapper/DocumentMapperParser.java @@ -36,6 +36,7 @@ import org.elasticsearch.index.analysis.AnalysisService; import org.elasticsearch.index.analysis.NamedAnalyzer; import org.elasticsearch.index.mapper.core.*; import org.elasticsearch.index.mapper.geo.GeoPointFieldMapper; +import org.elasticsearch.index.mapper.geo.GeoShapeFieldMapper; import org.elasticsearch.index.mapper.internal.*; import org.elasticsearch.index.mapper.ip.IpFieldMapper; import org.elasticsearch.index.mapper.multifield.MultiFieldMapper; @@ -85,6 +86,7 @@ public class DocumentMapperParser extends AbstractIndexComponent { .put(ObjectMapper.NESTED_CONTENT_TYPE, new ObjectMapper.TypeParser()) .put(MultiFieldMapper.CONTENT_TYPE, new MultiFieldMapper.TypeParser()) .put(GeoPointFieldMapper.CONTENT_TYPE, new GeoPointFieldMapper.TypeParser()) + .put(GeoShapeFieldMapper.CONTENT_TYPE, new GeoShapeFieldMapper.TypeParser()) .immutableMap(); rootTypeParsers = new MapBuilder() diff --git a/src/main/java/org/elasticsearch/index/mapper/geo/GeoShapeFieldMapper.java b/src/main/java/org/elasticsearch/index/mapper/geo/GeoShapeFieldMapper.java new file mode 100644 index 00000000000..4d066e64ad2 --- /dev/null +++ b/src/main/java/org/elasticsearch/index/mapper/geo/GeoShapeFieldMapper.java @@ -0,0 +1,186 @@ +package org.elasticsearch.index.mapper.geo; + +import com.spatial4j.core.context.SpatialContext; +import com.spatial4j.core.context.jts.JtsSpatialContext; +import com.spatial4j.core.distance.DistanceUnits; +import org.apache.lucene.document.Field; +import org.apache.lucene.document.Fieldable; +import org.elasticsearch.common.lucene.spatial.SpatialStrategy; +import org.elasticsearch.common.lucene.spatial.prefix.TermQueryPrefixTreeStrategy; +import org.elasticsearch.common.lucene.spatial.prefix.tree.GeohashPrefixTree; +import org.elasticsearch.common.lucene.spatial.prefix.tree.QuadPrefixTree; +import org.elasticsearch.ElasticSearchIllegalArgumentException; +import org.elasticsearch.common.Strings; +import org.elasticsearch.common.geo.GeoJSONShapeParser; +import org.elasticsearch.common.lucene.spatial.prefix.tree.SpatialPrefixTree; +import org.elasticsearch.common.xcontent.XContentBuilder; +import org.elasticsearch.index.mapper.FieldMapper; +import org.elasticsearch.index.mapper.Mapper; +import org.elasticsearch.index.mapper.MapperParsingException; +import org.elasticsearch.index.mapper.ParseContext; +import org.elasticsearch.index.mapper.core.AbstractFieldMapper; + +import java.io.IOException; +import java.util.Map; + +/** + * FieldMapper for indexing {@link com.spatial4j.core.shape.Shape}s. + * + * Currently Shapes can only be indexed and can only be queried using + * {@link org.elasticsearch.index.query.GeoShapeFilterParser}, consequently + * a lot of behavior in this Mapper is disabled. + * + * Format supported: + * + * "field" : { + * "type" : "polygon", + * "coordinates" : [ + * [ [100.0, 0.0], [101.0, 0.0], [101.0, 1.0], [100.0, 1.0], [100.0, 0.0] ] + * ] + * } + */ +public class GeoShapeFieldMapper extends AbstractFieldMapper { + + public static final String CONTENT_TYPE = "geo_shape"; + + // TODO: Unsure if the units actually matter since we dont do distance calculations + public static final SpatialContext SPATIAL_CONTEXT = new JtsSpatialContext(DistanceUnits.KILOMETERS); + + public static class Names { + public static final String TREE = "tree"; + public static final String TREE_LEVELS = "tree_levels"; + public static final String GEOHASH = "geohash"; + public static final String QUADTREE = "quadtree"; + public static final String DISTANCE_ERROR_PCT = "distance_error_pct"; + } + + public static class Defaults { + public static final String TREE = Names.GEOHASH; + public static final int GEOHASH_LEVELS = GeohashPrefixTree.getMaxLevelsPossible(); + public static final int QUADTREE_LEVELS = QuadPrefixTree.DEFAULT_MAX_LEVELS; + public static final double DISTANCE_ERROR_PCT = 0.025d; + } + + public static class Builder extends AbstractFieldMapper.Builder { + + private String tree = Defaults.TREE; + private int treeLevels; + private double distanceErrorPct = Defaults.DISTANCE_ERROR_PCT; + + private SpatialPrefixTree prefixTree; + + public Builder(String name) { + super(name); + } + + public Builder tree(String tree) { + this.tree = tree; + return this; + } + + public Builder treeLevels(int treeLevels) { + this.treeLevels = treeLevels; + return this; + } + + public Builder distanceErrorPct(double distanceErrorPct) { + this.distanceErrorPct = distanceErrorPct; + return this; + } + + @Override + public GeoShapeFieldMapper build(BuilderContext context) { + if (tree.equals(Names.GEOHASH)) { + int levels = treeLevels != 0 ? treeLevels : Defaults.GEOHASH_LEVELS; + prefixTree = new GeohashPrefixTree(SPATIAL_CONTEXT, levels); + } else if (tree.equals(Names.QUADTREE)) { + int levels = treeLevels != 0 ? treeLevels : Defaults.QUADTREE_LEVELS; + prefixTree = new QuadPrefixTree(SPATIAL_CONTEXT, levels); + } else { + throw new ElasticSearchIllegalArgumentException("Unknown prefix tree type [" + tree + "]"); + } + + return new GeoShapeFieldMapper(buildNames(context), prefixTree, distanceErrorPct); + } + } + + public static class TypeParser implements Mapper.TypeParser { + + @Override + public Mapper.Builder parse(String name, Map node, ParserContext parserContext) throws MapperParsingException { + Builder builder = new Builder(name); + + for (Map.Entry entry : node.entrySet()) { + String fieldName = Strings.toUnderscoreCase(entry.getKey()); + Object fieldNode = entry.getValue(); + if (Names.TREE.equals(fieldName)) { + builder.tree(fieldNode.toString()); + } else if (Names.TREE_LEVELS.equals(fieldName)) { + builder.treeLevels(Integer.parseInt(fieldNode.toString())); + } else if (Names.DISTANCE_ERROR_PCT.equals(fieldName)) { + builder.distanceErrorPct(Double.parseDouble(fieldNode.toString())); + } + } + return builder; + } + } + + private final SpatialStrategy spatialStrategy; + + public GeoShapeFieldMapper(FieldMapper.Names names, SpatialPrefixTree prefixTree, double distanceErrorPct) { + super(names, Field.Index.NOT_ANALYZED, Field.Store.NO, Field.TermVector.NO, 1, true, true, null, null); + this.spatialStrategy = new TermQueryPrefixTreeStrategy(names, prefixTree, distanceErrorPct); + } + + @Override + protected Fieldable parseCreateField(ParseContext context) throws IOException { + return spatialStrategy.createField(GeoJSONShapeParser.parse(context.parser())); + } + + @Override + protected void doXContentBody(XContentBuilder builder) throws IOException { + builder.field("type", contentType()); + + // TODO: Come up with a better way to get the name, maybe pass it from builder + if (spatialStrategy.getPrefixTree() instanceof GeohashPrefixTree) { + // Don't emit the tree name since GeohashPrefixTree is the default + // Only emit the tree levels if it isn't the default value + if (spatialStrategy.getPrefixTree().getMaxLevels() != Defaults.GEOHASH_LEVELS) { + builder.field(Names.TREE_LEVELS, spatialStrategy.getPrefixTree().getMaxLevels()); + } + } else { + builder.field(Names.TREE, Names.QUADTREE); + if (spatialStrategy.getPrefixTree().getMaxLevels() != Defaults.QUADTREE_LEVELS) { + builder.field(Names.TREE_LEVELS, spatialStrategy.getPrefixTree().getMaxLevels()); + } + } + + if (spatialStrategy.getDistanceErrorPct() != Defaults.DISTANCE_ERROR_PCT) { + builder.field(Names.DISTANCE_ERROR_PCT, spatialStrategy.getDistanceErrorPct()); + } + } + + @Override + protected String contentType() { + return CONTENT_TYPE; + } + + @Override + public String value(Fieldable field) { + throw new UnsupportedOperationException("GeoShape fields cannot be converted to String values"); + } + + @Override + public String valueFromString(String value) { + throw new UnsupportedOperationException("GeoShape fields cannot be converted to String values"); + } + + @Override + public String valueAsString(Fieldable field) { + throw new UnsupportedOperationException("GeoShape fields cannot be converted to String values"); + } + + public SpatialStrategy spatialStrategy() { + return this.spatialStrategy; + } +} diff --git a/src/main/java/org/elasticsearch/index/query/FilterBuilders.java b/src/main/java/org/elasticsearch/index/query/FilterBuilders.java index 793cc18f42a..4e6e8f0f5ac 100644 --- a/src/main/java/org/elasticsearch/index/query/FilterBuilders.java +++ b/src/main/java/org/elasticsearch/index/query/FilterBuilders.java @@ -19,6 +19,7 @@ package org.elasticsearch.index.query; +import com.spatial4j.core.shape.Shape; import org.elasticsearch.common.Nullable; /** @@ -338,6 +339,16 @@ public abstract class FilterBuilders { return new GeoPolygonFilterBuilder(name); } + /** + * A filter to filter based on the relationship between a shape and indexed shapes + * + * @param name The shape field name + * @param shape Shape to use in the filter + */ + public static GeoShapeFilterBuilder geoShapeFilter(String name, Shape shape) { + return new GeoShapeFilterBuilder(name, shape); + } + /** * A filter to filter only documents where a field exists in them. * diff --git a/src/main/java/org/elasticsearch/index/query/GeoShapeFilterBuilder.java b/src/main/java/org/elasticsearch/index/query/GeoShapeFilterBuilder.java new file mode 100644 index 00000000000..ac73fddc3c5 --- /dev/null +++ b/src/main/java/org/elasticsearch/index/query/GeoShapeFilterBuilder.java @@ -0,0 +1,108 @@ +package org.elasticsearch.index.query; + +import com.spatial4j.core.shape.Shape; +import org.elasticsearch.common.geo.GeoJSONShapeSerializer; +import org.elasticsearch.common.geo.ShapeRelation; +import org.elasticsearch.common.xcontent.XContentBuilder; + +import java.io.IOException; + +/** + * {@link FilterBuilder} that builds a GeoShape Filter + */ +public class GeoShapeFilterBuilder extends BaseFilterBuilder { + + private final String name; + + private ShapeRelation relation = ShapeRelation.INTERSECTS; + + private final Shape shape; + + private Boolean cache; + private String cacheKey; + + private String filterName; + + /** + * Creates a new GeoShapeFilterBuilder whose Filter will be against the + * given field name + * + * @param name Name of the field that will be filtered + * @param shape Shape used in the filter + */ + public GeoShapeFilterBuilder(String name, Shape shape) { + this.name = name; + this.shape = shape; + } + + /** + * Sets the {@link ShapeRelation} that defines how the Shape used in the + * Filter must relate to indexed Shapes + * + * @param relation ShapeRelation used in the filter + * @return this + */ + public GeoShapeFilterBuilder relation(ShapeRelation relation) { + this.relation = relation; + return this; + } + + /** + * Sets whether the filter will be cached. + * + * @param cache Whether filter will be cached + * @return this + */ + public GeoShapeFilterBuilder cache(boolean cache) { + this.cache = cache; + return this; + } + + /** + * Sets the key used for the filter if it is cached + * + * @param cacheKey Key for the Filter if cached + * @return this + */ + public GeoShapeFilterBuilder cacheKey(String cacheKey) { + this.cacheKey = cacheKey; + return this; + } + + /** + * Sets the name of the filter + * + * @param filterName Name of the filter + * @return this + */ + public GeoShapeFilterBuilder filterName(String filterName) { + this.filterName = filterName; + return this; + } + + @Override + protected void doXContent(XContentBuilder builder, Params params) throws IOException { + builder.startObject(GeoShapeFilterParser.NAME); + + builder.startObject(name); + builder.field("relation", relation.getRelationName()); + + builder.startObject("shape"); + GeoJSONShapeSerializer.serialize(shape, builder); + builder.endObject(); + + builder.endObject(); + + if (name != null) { + builder.field("_name", filterName); + } + if (cache != null) { + builder.field("_cache", cache); + } + if (cacheKey != null) { + builder.field("_cache_key", cacheKey); + } + + builder.endObject(); + } +} diff --git a/src/main/java/org/elasticsearch/index/query/GeoShapeFilterParser.java b/src/main/java/org/elasticsearch/index/query/GeoShapeFilterParser.java new file mode 100644 index 00000000000..62d444583f0 --- /dev/null +++ b/src/main/java/org/elasticsearch/index/query/GeoShapeFilterParser.java @@ -0,0 +1,122 @@ +package org.elasticsearch.index.query; + +import com.spatial4j.core.shape.Shape; +import org.apache.lucene.search.Filter; +import org.elasticsearch.common.geo.ShapeRelation; +import org.elasticsearch.common.geo.GeoJSONShapeParser; +import org.elasticsearch.common.xcontent.XContentParser; +import org.elasticsearch.index.cache.filter.support.CacheKeyFilter; +import org.elasticsearch.index.mapper.FieldMapper; +import org.elasticsearch.index.mapper.MapperService; +import org.elasticsearch.index.mapper.geo.GeoShapeFieldMapper; + +import java.io.IOException; + +import static org.elasticsearch.index.query.support.QueryParsers.wrapSmartNameFilter; + +/** + * {@link FilterParser} for filtering Documents based on {@link Shape}s. + * + * Only those fields mapped using {@link GeoShapeFieldMapper} can be filtered + * using this parser. + * + * Format supported: + * + * "field" : { + * "relation" : "intersects", + * "shape" : { + * "type" : "polygon", + * "coordinates" : [ + * [ [100.0, 0.0], [101.0, 0.0], [101.0, 1.0], [100.0, 1.0], [100.0, 0.0] ] + * ] + * } + * } + */ +public class GeoShapeFilterParser implements FilterParser { + + public static final String NAME = "geo_shape"; + + @Override + public String[] names() { + return new String[]{NAME, "geoShape"}; + } + + @Override + public Filter parse(QueryParseContext parseContext) throws IOException, QueryParsingException { + XContentParser parser = parseContext.parser(); + + String fieldName = null; + ShapeRelation shapeRelation = null; + Shape shape = null; + boolean cache = false; + CacheKeyFilter.Key cacheKey = null; + String filterName = null; + + XContentParser.Token token; + String currentFieldName = null; + + while ((token = parser.nextToken()) != XContentParser.Token.END_OBJECT) { + if (token == XContentParser.Token.FIELD_NAME) { + currentFieldName = parser.currentName(); + } else if (token == XContentParser.Token.START_OBJECT) { + fieldName = currentFieldName; + + while ((token = parser.nextToken()) != XContentParser.Token.END_OBJECT) { + if (token == XContentParser.Token.FIELD_NAME) { + currentFieldName = parser.currentName(); + + token = parser.nextToken(); + if ("shape".equals(currentFieldName)) { + shape = GeoJSONShapeParser.parse(parser); + } else if ("relation".equals(currentFieldName)) { + shapeRelation = ShapeRelation.getRelationByName(parser.text()); + if (shapeRelation == null) { + throw new QueryParsingException(parseContext.index(), "Unknown shape operation [" + parser.text() + " ]"); + } + } + } + } + } else if (token.isValue()) { + if ("_name".equals(currentFieldName)) { + filterName = parser.text(); + } else if ("_cache".equals(currentFieldName)) { + cache = parser.booleanValue(); + } else if ("_cache_key".equals(currentFieldName)) { + cacheKey = new CacheKeyFilter.Key(parser.text()); + } + } + } + + if (shape == null) { + throw new QueryParsingException(parseContext.index(), "No Shape defined"); + } else if (shapeRelation == null) { + throw new QueryParsingException(parseContext.index(), "No Shape Relation defined"); + } + + MapperService.SmartNameFieldMappers smartNameFieldMappers = parseContext.smartFieldMappers(fieldName); + if (smartNameFieldMappers == null || !smartNameFieldMappers.hasMapper()) { + throw new QueryParsingException(parseContext.index(), "Failed to find geo_shape field [" + fieldName + "]"); + } + + FieldMapper fieldMapper = smartNameFieldMappers.mapper(); + // TODO: This isn't the nicest way to check this + if (!(fieldMapper instanceof GeoShapeFieldMapper)) { + throw new QueryParsingException(parseContext.index(), "Field [" + fieldName + "] is not a geo_shape"); + } + + GeoShapeFieldMapper shapeFieldMapper = (GeoShapeFieldMapper) fieldMapper; + Filter filter = shapeFieldMapper.spatialStrategy().createFilter(shape, shapeRelation); + + if (cache) { + filter = parseContext.cacheFilter(filter, cacheKey); + } + + filter = wrapSmartNameFilter(filter, smartNameFieldMappers, parseContext); + + if (filterName != null) { + parseContext.addNamedFilter(filterName, filter); + } + + return filter; + } +} diff --git a/src/main/java/org/elasticsearch/index/query/GeoShapeQueryBuilder.java b/src/main/java/org/elasticsearch/index/query/GeoShapeQueryBuilder.java new file mode 100644 index 00000000000..27fdf308af7 --- /dev/null +++ b/src/main/java/org/elasticsearch/index/query/GeoShapeQueryBuilder.java @@ -0,0 +1,53 @@ +package org.elasticsearch.index.query; + +import com.spatial4j.core.shape.Shape; +import org.elasticsearch.common.geo.GeoJSONShapeSerializer; +import org.elasticsearch.common.geo.ShapeRelation; +import org.elasticsearch.common.xcontent.XContentBuilder; + +import java.io.IOException; + +public class GeoShapeQueryBuilder extends BaseQueryBuilder implements BoostableQueryBuilder { + + private final String name; + private final Shape shape; + + private ShapeRelation relation = ShapeRelation.INTERSECTS; + + private float boost = -1; + + public GeoShapeQueryBuilder(String name, Shape shape) { + this.name = name; + this.shape = shape; + } + + public GeoShapeQueryBuilder relation(ShapeRelation relation) { + this.relation = relation; + return this; + } + + @Override + public GeoShapeQueryBuilder boost(float boost) { + this.boost = boost; + return this; + } + + @Override + protected void doXContent(XContentBuilder builder, Params params) throws IOException { + builder.startObject(GeoShapeQueryParser.NAME); + + builder.startObject(name); + builder.field("relation", relation.getRelationName()); + + builder.startObject("shape"); + GeoJSONShapeSerializer.serialize(shape, builder); + builder.endObject(); + + if (boost != -1) { + builder.field("boost", boost); + } + + builder.endObject(); + } + +} diff --git a/src/main/java/org/elasticsearch/index/query/GeoShapeQueryParser.java b/src/main/java/org/elasticsearch/index/query/GeoShapeQueryParser.java new file mode 100644 index 00000000000..7724e1660ff --- /dev/null +++ b/src/main/java/org/elasticsearch/index/query/GeoShapeQueryParser.java @@ -0,0 +1,87 @@ +package org.elasticsearch.index.query; + +import com.spatial4j.core.shape.Shape; +import org.apache.lucene.search.Query; +import org.elasticsearch.common.Strings; +import org.elasticsearch.common.geo.GeoJSONShapeParser; +import org.elasticsearch.common.geo.ShapeRelation; +import org.elasticsearch.common.xcontent.XContentParser; +import org.elasticsearch.index.mapper.FieldMapper; +import org.elasticsearch.index.mapper.MapperService; +import org.elasticsearch.index.mapper.geo.GeoShapeFieldMapper; + +import java.io.IOException; + +public class GeoShapeQueryParser implements QueryParser { + + public static final String NAME = "geo_shape"; + + @Override + public String[] names() { + return new String[]{NAME, Strings.toCamelCase(NAME)}; + } + + @Override + public Query parse(QueryParseContext parseContext) throws IOException, QueryParsingException { + XContentParser parser = parseContext.parser(); + + String fieldName = null; + ShapeRelation shapeRelation = null; + Shape shape = null; + + XContentParser.Token token; + String currentFieldName = null; + float boost = 1f; + + while ((token = parser.nextToken()) != XContentParser.Token.END_OBJECT) { + if (token == XContentParser.Token.FIELD_NAME) { + currentFieldName = parser.currentName(); + } else if (token == XContentParser.Token.START_OBJECT) { + fieldName = currentFieldName; + + while ((token = parser.nextToken()) != XContentParser.Token.END_OBJECT) { + if (token == XContentParser.Token.FIELD_NAME) { + currentFieldName = parser.currentName(); + + token = parser.nextToken(); + if ("shape".equals(currentFieldName)) { + shape = GeoJSONShapeParser.parse(parser); + } else if ("relation".equals(currentFieldName)) { + shapeRelation = ShapeRelation.getRelationByName(parser.text()); + if (shapeRelation == null) { + throw new QueryParsingException(parseContext.index(), "Unknown shape operation [" + parser.text() + " ]"); + } + } + } + } + } else if (token.isValue()) { + if ("boost".equals(currentFieldName)) { + boost = parser.floatValue(); + } + } + } + + if (shape == null) { + throw new QueryParsingException(parseContext.index(), "No Shape defined"); + } else if (shapeRelation == null) { + throw new QueryParsingException(parseContext.index(), "No Shape Relation defined"); + } + + MapperService.SmartNameFieldMappers smartNameFieldMappers = parseContext.smartFieldMappers(fieldName); + if (smartNameFieldMappers == null || !smartNameFieldMappers.hasMapper()) { + throw new QueryParsingException(parseContext.index(), "Failed to find geo_shape field [" + fieldName + "]"); + } + + FieldMapper fieldMapper = smartNameFieldMappers.mapper(); + // TODO: This isn't the nicest way to check this + if (!(fieldMapper instanceof GeoShapeFieldMapper)) { + throw new QueryParsingException(parseContext.index(), "Field [" + fieldName + "] is not a geo_shape"); + } + + GeoShapeFieldMapper shapeFieldMapper = (GeoShapeFieldMapper) fieldMapper; + + Query query = shapeFieldMapper.spatialStrategy().createQuery(shape, shapeRelation); + query.setBoost(boost); + return query; + } +} diff --git a/src/main/java/org/elasticsearch/index/query/QueryBuilders.java b/src/main/java/org/elasticsearch/index/query/QueryBuilders.java index 98167f18441..53a323ba7f1 100644 --- a/src/main/java/org/elasticsearch/index/query/QueryBuilders.java +++ b/src/main/java/org/elasticsearch/index/query/QueryBuilders.java @@ -19,6 +19,7 @@ package org.elasticsearch.index.query; +import com.spatial4j.core.shape.Shape; import org.elasticsearch.common.Nullable; /** @@ -700,6 +701,17 @@ public abstract class QueryBuilders { return new WrapperQueryBuilder(source, offset, length); } + /** + * Query that matches Documents based on the relationship between the given shape and + * indexed shapes + * + * @param name The shape field name + * @param shape Shape to use in the Query + */ + public static GeoShapeQueryBuilder geoShapeQuery(String name, Shape shape) { + return new GeoShapeQueryBuilder(name, shape); + } + private QueryBuilders() { } diff --git a/src/main/java/org/elasticsearch/indices/query/IndicesQueriesRegistry.java b/src/main/java/org/elasticsearch/indices/query/IndicesQueriesRegistry.java index 85766ea3044..8dd0b4e4ee1 100644 --- a/src/main/java/org/elasticsearch/indices/query/IndicesQueriesRegistry.java +++ b/src/main/java/org/elasticsearch/indices/query/IndicesQueriesRegistry.java @@ -74,6 +74,7 @@ public class IndicesQueriesRegistry { addQueryParser(queryParsers, new FuzzyLikeThisFieldQueryParser()); addQueryParser(queryParsers, new WrapperQueryParser()); addQueryParser(queryParsers, new IndicesQueryParser(clusterService)); + addQueryParser(queryParsers, new GeoShapeQueryParser()); this.queryParsers = ImmutableMap.copyOf(queryParsers); Map filterParsers = Maps.newHashMap(); @@ -92,6 +93,7 @@ public class IndicesQueriesRegistry { addFilterParser(filterParsers, new GeoDistanceRangeFilterParser()); addFilterParser(filterParsers, new GeoBoundingBoxFilterParser()); addFilterParser(filterParsers, new GeoPolygonFilterParser()); + addFilterParser(filterParsers, new GeoShapeFilterParser()); addFilterParser(filterParsers, new QueryFilterParser()); addFilterParser(filterParsers, new FQueryFilterParser()); addFilterParser(filterParsers, new BoolFilterParser()); diff --git a/src/test/java/org/elasticsearch/test/integration/search/geo/GeoShapeIntegrationTests.java b/src/test/java/org/elasticsearch/test/integration/search/geo/GeoShapeIntegrationTests.java new file mode 100644 index 00000000000..1c5ca293b1f --- /dev/null +++ b/src/test/java/org/elasticsearch/test/integration/search/geo/GeoShapeIntegrationTests.java @@ -0,0 +1,98 @@ +package org.elasticsearch.test.integration.search.geo; + +import com.spatial4j.core.shape.Shape; +import org.elasticsearch.action.search.SearchResponse; +import org.elasticsearch.client.Client; +import org.elasticsearch.common.geo.ShapeRelation; +import org.elasticsearch.common.xcontent.XContentFactory; +import org.elasticsearch.index.query.GeoShapeFilterParser; +import org.elasticsearch.test.integration.AbstractNodesTests; +import org.testng.annotations.AfterClass; +import org.testng.annotations.BeforeClass; +import org.testng.annotations.Test; + +import static org.elasticsearch.common.geo.ShapeBuilder.newRectangle; +import static org.elasticsearch.common.xcontent.XContentFactory.jsonBuilder; +import static org.elasticsearch.index.query.FilterBuilders.geoShapeFilter; +import static org.elasticsearch.index.query.QueryBuilders.filteredQuery; +import static org.elasticsearch.index.query.QueryBuilders.geoShapeQuery; +import static org.elasticsearch.index.query.QueryBuilders.matchAllQuery; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; + +public class GeoShapeIntegrationTests extends AbstractNodesTests { + + private Client client; + + @BeforeClass + public void createNodes() throws Exception { + startNode("server1"); + startNode("server2"); + client = getClient(); + } + + @AfterClass + public void closeNodes() { + client.close(); + closeAllNodes(); + } + + protected Client getClient() { + return client("server1"); + } + + @Test + public void testIndexPointsFilterRectangle() throws Exception { + try { + client.admin().indices().prepareDelete("test").execute().actionGet(); + } catch (Exception e) { + // ignore + } + + String mapping = XContentFactory.jsonBuilder().startObject().startObject("type1") + .startObject("properties").startObject("location") + .field("type", "geo_shape") + .field("tree", "quadtree") + .endObject().endObject() + .endObject().endObject().string(); + client.admin().indices().prepareCreate("test").addMapping("type1", mapping).execute().actionGet(); + client.admin().cluster().prepareHealth().setWaitForGreenStatus().execute().actionGet(); + + client.prepareIndex("test", "type1", "1").setSource(jsonBuilder().startObject() + .field("name", "Document 1") + .startObject("location") + .field("type", "point") + .startArray("coordinates").value(-30).value(-30).endArray() + .endObject() + .endObject()).execute().actionGet(); + + client.prepareIndex("test", "type1", "2").setSource(jsonBuilder().startObject() + .field("name", "Document 2") + .startObject("location") + .field("type", "point") + .startArray("coordinates").value(-45).value(-50).endArray() + .endObject() + .endObject()).execute().actionGet(); + + client.admin().indices().prepareRefresh().execute().actionGet(); + + Shape shape = newRectangle().topLeft(-45, 45).bottomRight(45, -45).build(); + + SearchResponse searchResponse = client.prepareSearch() + .setQuery(filteredQuery(matchAllQuery(), + geoShapeFilter("location", shape).relation(ShapeRelation.INTERSECTS))) + .execute().actionGet(); + + assertThat(searchResponse.hits().getTotalHits(), equalTo(1l)); + assertThat(searchResponse.hits().hits().length, equalTo(1)); + assertThat(searchResponse.hits().getAt(0).id(), equalTo("1")); + + searchResponse = client.prepareSearch() + .setQuery(geoShapeQuery("location", shape).relation(ShapeRelation.INTERSECTS)) + .execute().actionGet(); + + assertThat(searchResponse.hits().getTotalHits(), equalTo(1l)); + assertThat(searchResponse.hits().hits().length, equalTo(1)); + assertThat(searchResponse.hits().getAt(0).id(), equalTo("1")); + } +} diff --git a/src/test/java/org/elasticsearch/test/unit/common/geo/GeoJSONShapeParserTests.java b/src/test/java/org/elasticsearch/test/unit/common/geo/GeoJSONShapeParserTests.java new file mode 100644 index 00000000000..dfdcfa670e4 --- /dev/null +++ b/src/test/java/org/elasticsearch/test/unit/common/geo/GeoJSONShapeParserTests.java @@ -0,0 +1,147 @@ +package org.elasticsearch.test.unit.common.geo; + +import com.spatial4j.core.shape.Shape; +import com.spatial4j.core.shape.jts.JtsGeometry; +import com.spatial4j.core.shape.jts.JtsPoint; +import com.vividsolutions.jts.geom.*; +import org.elasticsearch.common.geo.GeoJSONShapeParser; +import org.elasticsearch.common.xcontent.XContentFactory; +import org.elasticsearch.common.xcontent.XContentParser; +import org.elasticsearch.common.xcontent.json.JsonXContent; +import org.testng.annotations.Test; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import static org.testng.Assert.assertEquals; + +/** + * Tests for {@link GeoJSONShapeParser} + */ +public class GeoJSONShapeParserTests { + + private final static GeometryFactory GEOMETRY_FACTORY = new GeometryFactory(); + + @Test + public void testParse_simplePoint() throws IOException { + String pointGeoJson = XContentFactory.jsonBuilder().startObject().field("type", "Point") + .startArray("coordinates").value(100.0).value(0.0).endArray() + .endObject().string(); + + Point expected = GEOMETRY_FACTORY.createPoint(new Coordinate(100.0, 0.0)); + assertGeometryEquals(new JtsPoint(expected), pointGeoJson); + } + + @Test + public void testParse_lineString() throws IOException { + String lineGeoJson = XContentFactory.jsonBuilder().startObject().field("type", "LineString") + .startArray("coordinates") + .startArray().value(100.0).value(0.0).endArray() + .startArray().value(101.0).value(1.0).endArray() + .endArray() + .endObject().string(); + + List lineCoordinates = new ArrayList(); + lineCoordinates.add(new Coordinate(100, 0)); + lineCoordinates.add(new Coordinate(101, 1)); + + LineString expected = GEOMETRY_FACTORY.createLineString( + lineCoordinates.toArray(new Coordinate[lineCoordinates.size()])); + assertGeometryEquals(new JtsGeometry(expected), lineGeoJson); + } + + @Test + public void testParse_polygonNoHoles() throws IOException { + String polygonGeoJson = XContentFactory.jsonBuilder().startObject().field("type", "Polygon") + .startArray("coordinates") + .startArray() + .startArray().value(100.0).value(0.0).endArray() + .startArray().value(101.0).value(0.0).endArray() + .startArray().value(101.0).value(1.0).endArray() + .startArray().value(100.0).value(1.0).endArray() + .startArray().value(100.0).value(0.0).endArray() + .endArray() + .endArray() + .endObject().string(); + + List shellCoordinates = new ArrayList(); + shellCoordinates.add(new Coordinate(100, 0)); + shellCoordinates.add(new Coordinate(101, 0)); + shellCoordinates.add(new Coordinate(101, 1)); + shellCoordinates.add(new Coordinate(100, 1)); + shellCoordinates.add(new Coordinate(100, 0)); + + LinearRing shell = GEOMETRY_FACTORY.createLinearRing( + shellCoordinates.toArray(new Coordinate[shellCoordinates.size()])); + Polygon expected = GEOMETRY_FACTORY.createPolygon(shell, null); + assertGeometryEquals(new JtsGeometry(expected), polygonGeoJson); + } + + @Test + public void testParse_polygonWithHole() throws IOException { + String polygonGeoJson = XContentFactory.jsonBuilder().startObject().field("type", "Polygon") + .startArray("coordinates") + .startArray() + .startArray().value(100.0).value(0.0).endArray() + .startArray().value(101.0).value(0.0).endArray() + .startArray().value(101.0).value(1.0).endArray() + .startArray().value(100.0).value(1.0).endArray() + .startArray().value(100.0).value(0.0).endArray() + .endArray() + .startArray() + .startArray().value(100.2).value(0.2).endArray() + .startArray().value(100.8).value(0.2).endArray() + .startArray().value(100.8).value(0.8).endArray() + .startArray().value(100.2).value(0.8).endArray() + .startArray().value(100.2).value(0.2).endArray() + .endArray() + .endArray() + .endObject().string(); + + List shellCoordinates = new ArrayList(); + shellCoordinates.add(new Coordinate(100, 0)); + shellCoordinates.add(new Coordinate(101, 0)); + shellCoordinates.add(new Coordinate(101, 1)); + shellCoordinates.add(new Coordinate(100, 1)); + shellCoordinates.add(new Coordinate(100, 0)); + + List holeCoordinates = new ArrayList(); + holeCoordinates.add(new Coordinate(100.2, 0.2)); + holeCoordinates.add(new Coordinate(100.8, 0.2)); + holeCoordinates.add(new Coordinate(100.8, 0.8)); + holeCoordinates.add(new Coordinate(100.2, 0.8)); + holeCoordinates.add(new Coordinate(100.2, 0.2)); + + LinearRing shell = GEOMETRY_FACTORY.createLinearRing( + shellCoordinates.toArray(new Coordinate[shellCoordinates.size()])); + LinearRing[] holes = new LinearRing[1]; + holes[0] = GEOMETRY_FACTORY.createLinearRing( + holeCoordinates.toArray(new Coordinate[holeCoordinates.size()])); + Polygon expected = GEOMETRY_FACTORY.createPolygon(shell, holes); + assertGeometryEquals(new JtsGeometry(expected), polygonGeoJson); + } + + @Test + public void testParse_multiPoint() throws IOException { + String multiPointGeoJson = XContentFactory.jsonBuilder().startObject().field("type", "MultiPoint") + .startArray("coordinates") + .startArray().value(100.0).value(0.0).endArray() + .startArray().value(101.0).value(1.0).endArray() + .endArray() + .endObject().string(); + + List multiPointCoordinates = new ArrayList(); + multiPointCoordinates.add(new Coordinate(100, 0)); + multiPointCoordinates.add(new Coordinate(101, 1)); + + MultiPoint expected = GEOMETRY_FACTORY.createMultiPoint( + multiPointCoordinates.toArray(new Coordinate[multiPointCoordinates.size()])); + assertGeometryEquals(new JtsGeometry(expected), multiPointGeoJson); + } + + private void assertGeometryEquals(Shape expected, String geoJson) throws IOException { + XContentParser parser = JsonXContent.jsonXContent.createParser(geoJson); + assertEquals(GeoJSONShapeParser.parse(parser), expected); + } +} diff --git a/src/test/java/org/elasticsearch/test/unit/common/geo/GeoJSONShapeSerializerTests.java b/src/test/java/org/elasticsearch/test/unit/common/geo/GeoJSONShapeSerializerTests.java new file mode 100644 index 00000000000..007076566bd --- /dev/null +++ b/src/test/java/org/elasticsearch/test/unit/common/geo/GeoJSONShapeSerializerTests.java @@ -0,0 +1,153 @@ +package org.elasticsearch.test.unit.common.geo; + +import com.spatial4j.core.shape.Shape; +import com.spatial4j.core.shape.jts.JtsGeometry; +import com.spatial4j.core.shape.jts.JtsPoint; +import com.vividsolutions.jts.geom.*; +import org.elasticsearch.common.geo.GeoJSONShapeSerializer; +import org.elasticsearch.common.xcontent.XContentBuilder; +import org.elasticsearch.common.xcontent.XContentFactory; +import org.testng.annotations.Test; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import static org.testng.Assert.assertEquals; + +/** + * Tests for {@link GeoJSONShapeSerializer} + */ +public class GeoJSONShapeSerializerTests { + + private static final GeometryFactory GEOMETRY_FACTORY = new GeometryFactory(); + + @Test + public void testSerialize_simplePoint() throws IOException { + XContentBuilder expected = XContentFactory.jsonBuilder().startObject().field("type", "Point") + .startArray("coordinates").value(100.0).value(0.0).endArray() + .endObject(); + + Point point = GEOMETRY_FACTORY.createPoint(new Coordinate(100.0, 0.0)); + assertSerializationEquals(expected, new JtsPoint(point)); + } + + @Test + public void testSerialize_lineString() throws IOException { + XContentBuilder expected = XContentFactory.jsonBuilder().startObject().field("type", "LineString") + .startArray("coordinates") + .startArray().value(100.0).value(0.0).endArray() + .startArray().value(101.0).value(1.0).endArray() + .endArray() + .endObject(); + + List lineCoordinates = new ArrayList(); + lineCoordinates.add(new Coordinate(100, 0)); + lineCoordinates.add(new Coordinate(101, 1)); + + LineString lineString = GEOMETRY_FACTORY.createLineString( + lineCoordinates.toArray(new Coordinate[lineCoordinates.size()])); + + assertSerializationEquals(expected, new JtsGeometry(lineString)); + } + + @Test + public void testSerialize_polygonNoHoles() throws IOException { + XContentBuilder expected = XContentFactory.jsonBuilder().startObject().field("type", "Polygon") + .startArray("coordinates") + .startArray() + .startArray().value(100.0).value(0.0).endArray() + .startArray().value(101.0).value(0.0).endArray() + .startArray().value(101.0).value(1.0).endArray() + .startArray().value(100.0).value(1.0).endArray() + .startArray().value(100.0).value(0.0).endArray() + .endArray() + .endArray() + .endObject(); + + List shellCoordinates = new ArrayList(); + shellCoordinates.add(new Coordinate(100, 0)); + shellCoordinates.add(new Coordinate(101, 0)); + shellCoordinates.add(new Coordinate(101, 1)); + shellCoordinates.add(new Coordinate(100, 1)); + shellCoordinates.add(new Coordinate(100, 0)); + + LinearRing shell = GEOMETRY_FACTORY.createLinearRing( + shellCoordinates.toArray(new Coordinate[shellCoordinates.size()])); + Polygon polygon = GEOMETRY_FACTORY.createPolygon(shell, null); + + assertSerializationEquals(expected, new JtsGeometry(polygon)); + } + + @Test + public void testSerialize_polygonWithHole() throws IOException { + XContentBuilder expected = XContentFactory.jsonBuilder().startObject().field("type", "Polygon") + .startArray("coordinates") + .startArray() + .startArray().value(100.0).value(0.0).endArray() + .startArray().value(101.0).value(0.0).endArray() + .startArray().value(101.0).value(1.0).endArray() + .startArray().value(100.0).value(1.0).endArray() + .startArray().value(100.0).value(0.0).endArray() + .endArray() + .startArray() + .startArray().value(100.2).value(0.2).endArray() + .startArray().value(100.8).value(0.2).endArray() + .startArray().value(100.8).value(0.8).endArray() + .startArray().value(100.2).value(0.8).endArray() + .startArray().value(100.2).value(0.2).endArray() + .endArray() + .endArray() + .endObject(); + + List shellCoordinates = new ArrayList(); + shellCoordinates.add(new Coordinate(100, 0)); + shellCoordinates.add(new Coordinate(101, 0)); + shellCoordinates.add(new Coordinate(101, 1)); + shellCoordinates.add(new Coordinate(100, 1)); + shellCoordinates.add(new Coordinate(100, 0)); + + List holeCoordinates = new ArrayList(); + holeCoordinates.add(new Coordinate(100.2, 0.2)); + holeCoordinates.add(new Coordinate(100.8, 0.2)); + holeCoordinates.add(new Coordinate(100.8, 0.8)); + holeCoordinates.add(new Coordinate(100.2, 0.8)); + holeCoordinates.add(new Coordinate(100.2, 0.2)); + + LinearRing shell = GEOMETRY_FACTORY.createLinearRing( + shellCoordinates.toArray(new Coordinate[shellCoordinates.size()])); + LinearRing[] holes = new LinearRing[1]; + holes[0] = GEOMETRY_FACTORY.createLinearRing( + holeCoordinates.toArray(new Coordinate[holeCoordinates.size()])); + Polygon polygon = GEOMETRY_FACTORY.createPolygon(shell, holes); + + assertSerializationEquals(expected, new JtsGeometry(polygon)); + } + + @Test + public void testSerialize_multiPoint() throws IOException { + XContentBuilder expected = XContentFactory.jsonBuilder().startObject().field("type", "MultiPoint") + .startArray("coordinates") + .startArray().value(100.0).value(0.0).endArray() + .startArray().value(101.0).value(1.0).endArray() + .endArray() + .endObject(); + + List multiPointCoordinates = new ArrayList(); + multiPointCoordinates.add(new Coordinate(100, 0)); + multiPointCoordinates.add(new Coordinate(101, 1)); + + MultiPoint multiPoint = GEOMETRY_FACTORY.createMultiPoint( + multiPointCoordinates.toArray(new Coordinate[multiPointCoordinates.size()])); + + assertSerializationEquals(expected, new JtsGeometry(multiPoint)); + } + + private void assertSerializationEquals(XContentBuilder expected, Shape shape) throws IOException { + XContentBuilder builder = XContentFactory.jsonBuilder(); + builder.startObject(); + GeoJSONShapeSerializer.serialize(shape, builder); + builder.endObject(); + assertEquals(expected.string(), builder.string()); + } +} diff --git a/src/test/java/org/elasticsearch/test/unit/common/geo/ShapeBuilderTests.java b/src/test/java/org/elasticsearch/test/unit/common/geo/ShapeBuilderTests.java new file mode 100644 index 00000000000..f9f18d8258b --- /dev/null +++ b/src/test/java/org/elasticsearch/test/unit/common/geo/ShapeBuilderTests.java @@ -0,0 +1,73 @@ +package org.elasticsearch.test.unit.common.geo; + +import com.spatial4j.core.shape.Point; +import com.spatial4j.core.shape.Rectangle; +import com.spatial4j.core.shape.Shape; +import com.vividsolutions.jts.geom.Coordinate; +import com.vividsolutions.jts.geom.Geometry; +import com.vividsolutions.jts.geom.LineString; +import com.vividsolutions.jts.geom.Polygon; +import org.elasticsearch.common.geo.ShapeBuilder; +import org.testng.annotations.Test; + +import static org.testng.Assert.assertEquals; + +/** + * Tests for {@link ShapeBuilder} + */ +public class ShapeBuilderTests { + + @Test + public void testNewPoint() { + Point point = ShapeBuilder.newPoint(-100, 45); + assertEquals(-100D, point.getX()); + assertEquals(45D, point.getY()); + } + + @Test + public void testNewRectangle() { + Rectangle rectangle = ShapeBuilder.newRectangle().topLeft(-45, 30).bottomRight(45, -30).build(); + assertEquals(-45D, rectangle.getMinX()); + assertEquals(-30D, rectangle.getMinY()); + assertEquals(45D, rectangle.getMaxX()); + assertEquals(30D, rectangle.getMaxY()); + } + + @Test + public void testNewPolygon() { + Polygon polygon = ShapeBuilder.newPolygon() + .point(-45, 30) + .point(45, 30) + .point(45, -30) + .point(-45, -30) + .point(-45, 30).toPolygon(); + + LineString exterior = polygon.getExteriorRing(); + assertEquals(exterior.getCoordinateN(0), new Coordinate(-45, 30)); + assertEquals(exterior.getCoordinateN(1), new Coordinate(45, 30)); + assertEquals(exterior.getCoordinateN(2), new Coordinate(45, -30)); + assertEquals(exterior.getCoordinateN(3), new Coordinate(-45, -30)); + } + + @Test + public void testToJTSGeometry() { + ShapeBuilder.PolygonBuilder polygonBuilder = ShapeBuilder.newPolygon() + .point(-45, 30) + .point(45, 30) + .point(45, -30) + .point(-45, -30) + .point(-45, 30); + + Shape polygon = polygonBuilder.build(); + Geometry polygonGeometry = ShapeBuilder.toJTSGeometry(polygon); + assertEquals(polygonBuilder.toPolygon(), polygonGeometry); + + Rectangle rectangle = ShapeBuilder.newRectangle().topLeft(-45, 30).bottomRight(45, -30).build(); + Geometry rectangleGeometry = ShapeBuilder.toJTSGeometry(rectangle); + assertEquals(rectangleGeometry, polygonGeometry); + + Point point = ShapeBuilder.newPoint(-45, 30); + Geometry pointGeometry = ShapeBuilder.toJTSGeometry(point); + assertEquals(pointGeometry.getCoordinate(), new Coordinate(-45, 30)); + } +} diff --git a/src/test/java/org/elasticsearch/test/unit/common/lucene/spatial/prefix/TermQueryPrefixTreeStrategyTests.java b/src/test/java/org/elasticsearch/test/unit/common/lucene/spatial/prefix/TermQueryPrefixTreeStrategyTests.java new file mode 100644 index 00000000000..34e0ade9d5a --- /dev/null +++ b/src/test/java/org/elasticsearch/test/unit/common/lucene/spatial/prefix/TermQueryPrefixTreeStrategyTests.java @@ -0,0 +1,165 @@ +package org.elasticsearch.test.unit.common.lucene.spatial.prefix; + +import com.spatial4j.core.context.SpatialContext; +import com.spatial4j.core.context.jts.JtsSpatialContext; +import com.spatial4j.core.distance.DistanceUnits; +import com.spatial4j.core.shape.Rectangle; +import com.spatial4j.core.shape.Shape; +import org.apache.lucene.analysis.KeywordAnalyzer; +import org.apache.lucene.document.Document; +import org.apache.lucene.document.Field; +import org.apache.lucene.index.IndexReader; +import org.apache.lucene.index.IndexWriter; +import org.apache.lucene.index.IndexWriterConfig; +import org.apache.lucene.search.*; +import org.apache.lucene.store.Directory; +import org.apache.lucene.store.RAMDirectory; +import org.apache.lucene.util.IOUtils; +import org.apache.lucene.util.Version; +import org.elasticsearch.common.lucene.spatial.prefix.TermQueryPrefixTreeStrategy; +import org.elasticsearch.common.lucene.spatial.prefix.tree.GeohashPrefixTree; +import org.elasticsearch.common.lucene.spatial.prefix.tree.QuadPrefixTree; +import org.elasticsearch.common.lucene.spatial.prefix.tree.SpatialPrefixTree; +import org.elasticsearch.index.mapper.FieldMapper; +import org.testng.annotations.AfterTest; +import org.testng.annotations.BeforeTest; +import org.testng.annotations.Test; + +import java.io.IOException; +import java.util.HashSet; +import java.util.Set; + +import static org.elasticsearch.common.geo.ShapeBuilder.*; +import static org.testng.Assert.assertTrue; + +/** + * Tests for {@link TermQueryPrefixTreeStrategy} + */ +public class TermQueryPrefixTreeStrategyTests { + + private static final SpatialContext SPATIAL_CONTEXT = new JtsSpatialContext(DistanceUnits.KILOMETERS); + + // TODO: Randomize the implementation choice + private static final SpatialPrefixTree QUAD_PREFIX_TREE = + new QuadPrefixTree(SPATIAL_CONTEXT, QuadPrefixTree.DEFAULT_MAX_LEVELS); + private static final SpatialPrefixTree GEOHASH_PREFIX_TREE + = new GeohashPrefixTree(SPATIAL_CONTEXT, GeohashPrefixTree.getMaxLevelsPossible()); + + private static final TermQueryPrefixTreeStrategy STRATEGY = new TermQueryPrefixTreeStrategy(new FieldMapper.Names("shape"), GEOHASH_PREFIX_TREE, 0.025); + + private Directory directory; + private IndexReader indexReader; + private IndexSearcher indexSearcher; + + @BeforeTest + public void setUp() throws IOException { + directory = new RAMDirectory(); + IndexWriter writer = new IndexWriter(directory, new IndexWriterConfig(Version.LUCENE_36, new KeywordAnalyzer())); + + writer.addDocument(newDocument("1", newPoint(-30, -30))); + writer.addDocument(newDocument("2", newPoint(-45, -45))); + writer.addDocument(newDocument("3", newPoint(-45, 50))); + writer.addDocument(newDocument("4", newRectangle().topLeft(-50, 50).bottomRight(-38, 38).build())); + + indexReader = IndexReader.open(writer, true); + indexSearcher = new IndexSearcher(indexReader); + } + + private Document newDocument(String id, Shape shape) { + Document document = new Document(); + document.add(new Field("id", id, Field.Store.YES, Field.Index.NOT_ANALYZED)); + document.add(STRATEGY.createField(shape)); + return document; + } + + private void assertTopDocs(TopDocs topDocs, String... ids) throws IOException { + assertTrue(ids.length <= topDocs.totalHits, "Query has more hits than expected"); + + Set foundIDs = new HashSet(); + for (ScoreDoc doc : topDocs.scoreDocs) { + Document foundDocument = indexSearcher.doc(doc.doc); + foundIDs.add(foundDocument.getFieldable("id").stringValue()); + } + + for (String id : ids) { + assertTrue(foundIDs.contains(id), "ID [" + id + "] was not found in query results"); + } + } + + @Test + public void testIntersectionRelation() throws IOException { + Rectangle rectangle = newRectangle().topLeft(-45, 45).bottomRight(45, -45).build(); + + Filter filter = STRATEGY.createIntersectsFilter(rectangle); + assertTopDocs(indexSearcher.search(new MatchAllDocsQuery(), filter, 10), "1", "2", "4"); + + Query query = STRATEGY.createIntersectsQuery(rectangle); + assertTopDocs(indexSearcher.search(query, 10), "1", "2", "4"); + + Shape polygon = newPolygon() + .point(-45, 45) + .point(45, 45) + .point(45, -45) + .point(-45, -45) + .point(-45, 45).build(); + + filter = STRATEGY.createIntersectsFilter(polygon); + assertTopDocs(indexSearcher.search(new MatchAllDocsQuery(), filter, 10), "1", "2", "4"); + + query = STRATEGY.createIntersectsQuery(polygon); + assertTopDocs(indexSearcher.search(query, 10), "1", "2", "4"); + } + + @Test + public void testDisjointRelation() throws IOException { + Rectangle rectangle = newRectangle().topLeft(-45, 45).bottomRight(45, -45).build(); + + Filter filter = STRATEGY.createDisjointFilter(rectangle); + assertTopDocs(indexSearcher.search(new MatchAllDocsQuery(), filter, 10), "3"); + + Query query = STRATEGY.createDisjointQuery(rectangle); + assertTopDocs(indexSearcher.search(query, 10), "3"); + + Shape polygon = newPolygon() + .point(-45, 45) + .point(45, 45) + .point(45, -45) + .point(-45, -45) + .point(-45, 45).build(); + + filter = STRATEGY.createDisjointFilter(polygon); + assertTopDocs(indexSearcher.search(new MatchAllDocsQuery(), filter, 10), "3"); + + query = STRATEGY.createDisjointQuery(polygon); + assertTopDocs(indexSearcher.search(query, 10), "3"); + } + + @Test + public void testContainsRelation() throws IOException { + Rectangle rectangle = newRectangle().topLeft(-45, 45).bottomRight(45, -45).build(); + + Filter filter = STRATEGY.createContainsFilter(rectangle); + assertTopDocs(indexSearcher.search(new MatchAllDocsQuery(), filter, 10), "1"); + + Query query = STRATEGY.createContainsQuery(rectangle); + assertTopDocs(indexSearcher.search(query, 10), "1"); + + Shape polygon = newPolygon() + .point(-45, 45) + .point(45, 45) + .point(45, -45) + .point(-45, -45) + .point(-45, 45).build(); + + filter = STRATEGY.createContainsFilter(polygon); + assertTopDocs(indexSearcher.search(new MatchAllDocsQuery(), filter, 10), "1"); + + query = STRATEGY.createContainsQuery(polygon); + assertTopDocs(indexSearcher.search(query, 10), "1"); + } + + @AfterTest + public void tearDown() throws IOException { + IOUtils.close(indexSearcher, indexReader, directory); + } +} diff --git a/src/test/java/org/elasticsearch/test/unit/index/mapper/geo/GeoShapeFieldMapperTests.java b/src/test/java/org/elasticsearch/test/unit/index/mapper/geo/GeoShapeFieldMapperTests.java new file mode 100644 index 00000000000..2a3ca864bbf --- /dev/null +++ b/src/test/java/org/elasticsearch/test/unit/index/mapper/geo/GeoShapeFieldMapperTests.java @@ -0,0 +1,86 @@ +package org.elasticsearch.test.unit.index.mapper.geo; + +import org.elasticsearch.common.lucene.spatial.SpatialStrategy; +import org.elasticsearch.common.lucene.spatial.prefix.tree.GeohashPrefixTree; +import org.elasticsearch.common.lucene.spatial.prefix.tree.QuadPrefixTree; +import org.elasticsearch.common.xcontent.XContentFactory; +import org.elasticsearch.index.mapper.DocumentMapper; +import org.elasticsearch.index.mapper.FieldMapper; +import org.elasticsearch.index.mapper.geo.GeoShapeFieldMapper; +import org.elasticsearch.test.unit.index.mapper.MapperTests; +import org.testng.annotations.Test; + +import java.io.IOException; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.instanceOf; + +public class GeoShapeFieldMapperTests { + + @Test + public void testDefaultConfiguration() throws IOException { + String mapping = XContentFactory.jsonBuilder().startObject().startObject("type1") + .startObject("properties").startObject("location") + .field("type", "geo_shape") + .endObject().endObject() + .endObject().endObject().string(); + + DocumentMapper defaultMapper = MapperTests.newParser().parse(mapping); + FieldMapper fieldMapper = defaultMapper.mappers().name("location").mapper(); + assertThat(fieldMapper, instanceOf(GeoShapeFieldMapper.class)); + + GeoShapeFieldMapper geoShapeFieldMapper = (GeoShapeFieldMapper) fieldMapper; + SpatialStrategy strategy = geoShapeFieldMapper.spatialStrategy(); + + assertThat(strategy.getDistanceErrorPct(), equalTo(GeoShapeFieldMapper.Defaults.DISTANCE_ERROR_PCT)); + assertThat(strategy.getPrefixTree(), instanceOf(GeohashPrefixTree.class)); + assertThat(strategy.getPrefixTree().getMaxLevels(), equalTo(GeoShapeFieldMapper.Defaults.GEOHASH_LEVELS)); + } + + @Test + public void testGeohashConfiguration() throws IOException { + String mapping = XContentFactory.jsonBuilder().startObject().startObject("type1") + .startObject("properties").startObject("location") + .field("type", "geo_shape") + .field("tree", "geohash") + .field("tree_levels", "4") + .field("distance_error_pct", "0.1") + .endObject().endObject() + .endObject().endObject().string(); + + DocumentMapper defaultMapper = MapperTests.newParser().parse(mapping); + FieldMapper fieldMapper = defaultMapper.mappers().name("location").mapper(); + assertThat(fieldMapper, instanceOf(GeoShapeFieldMapper.class)); + + GeoShapeFieldMapper geoShapeFieldMapper = (GeoShapeFieldMapper) fieldMapper; + SpatialStrategy strategy = geoShapeFieldMapper.spatialStrategy(); + + assertThat(strategy.getDistanceErrorPct(), equalTo(0.1)); + assertThat(strategy.getPrefixTree(), instanceOf(GeohashPrefixTree.class)); + assertThat(strategy.getPrefixTree().getMaxLevels(), equalTo(4)); + } + + @Test + public void testQuadtreeConfiguration() throws IOException { + String mapping = XContentFactory.jsonBuilder().startObject().startObject("type1") + .startObject("properties").startObject("location") + .field("type", "geo_shape") + .field("tree", "quadtree") + .field("tree_levels", "6") + .field("distance_error_pct", "0.5") + .endObject().endObject() + .endObject().endObject().string(); + + DocumentMapper defaultMapper = MapperTests.newParser().parse(mapping); + FieldMapper fieldMapper = defaultMapper.mappers().name("location").mapper(); + assertThat(fieldMapper, instanceOf(GeoShapeFieldMapper.class)); + + GeoShapeFieldMapper geoShapeFieldMapper = (GeoShapeFieldMapper) fieldMapper; + SpatialStrategy strategy = geoShapeFieldMapper.spatialStrategy(); + + assertThat(strategy.getDistanceErrorPct(), equalTo(0.5)); + assertThat(strategy.getPrefixTree(), instanceOf(QuadPrefixTree.class)); + assertThat(strategy.getPrefixTree().getMaxLevels(), equalTo(6)); + } +} diff --git a/src/test/java/org/elasticsearch/test/unit/index/mapper/geopoint/GeohashMappingGeoPointTests.java b/src/test/java/org/elasticsearch/test/unit/index/mapper/geo/GeohashMappingGeoPointTests.java similarity index 98% rename from src/test/java/org/elasticsearch/test/unit/index/mapper/geopoint/GeohashMappingGeoPointTests.java rename to src/test/java/org/elasticsearch/test/unit/index/mapper/geo/GeohashMappingGeoPointTests.java index 60dae6f9eee..64feebec0b1 100644 --- a/src/test/java/org/elasticsearch/test/unit/index/mapper/geopoint/GeohashMappingGeoPointTests.java +++ b/src/test/java/org/elasticsearch/test/unit/index/mapper/geo/GeohashMappingGeoPointTests.java @@ -17,7 +17,7 @@ * under the License. */ -package org.elasticsearch.test.unit.index.mapper.geopoint; +package org.elasticsearch.test.unit.index.mapper.geo; import org.elasticsearch.common.xcontent.XContentFactory; import org.elasticsearch.index.mapper.DocumentMapper; diff --git a/src/test/java/org/elasticsearch/test/unit/index/mapper/geopoint/LatLonAndGeohashMappingGeoPointTests.java b/src/test/java/org/elasticsearch/test/unit/index/mapper/geo/LatLonAndGeohashMappingGeoPointTests.java similarity index 98% rename from src/test/java/org/elasticsearch/test/unit/index/mapper/geopoint/LatLonAndGeohashMappingGeoPointTests.java rename to src/test/java/org/elasticsearch/test/unit/index/mapper/geo/LatLonAndGeohashMappingGeoPointTests.java index 3a739c334e3..66418c25780 100644 --- a/src/test/java/org/elasticsearch/test/unit/index/mapper/geopoint/LatLonAndGeohashMappingGeoPointTests.java +++ b/src/test/java/org/elasticsearch/test/unit/index/mapper/geo/LatLonAndGeohashMappingGeoPointTests.java @@ -17,7 +17,7 @@ * under the License. */ -package org.elasticsearch.test.unit.index.mapper.geopoint; +package org.elasticsearch.test.unit.index.mapper.geo; import org.elasticsearch.common.xcontent.XContentFactory; import org.elasticsearch.index.mapper.DocumentMapper; diff --git a/src/test/java/org/elasticsearch/test/unit/index/mapper/geopoint/LatLonMappingGeoPointTests.java b/src/test/java/org/elasticsearch/test/unit/index/mapper/geo/LatLonMappingGeoPointTests.java similarity index 99% rename from src/test/java/org/elasticsearch/test/unit/index/mapper/geopoint/LatLonMappingGeoPointTests.java rename to src/test/java/org/elasticsearch/test/unit/index/mapper/geo/LatLonMappingGeoPointTests.java index 9e74dfa426b..0e926803326 100644 --- a/src/test/java/org/elasticsearch/test/unit/index/mapper/geopoint/LatLonMappingGeoPointTests.java +++ b/src/test/java/org/elasticsearch/test/unit/index/mapper/geo/LatLonMappingGeoPointTests.java @@ -17,7 +17,7 @@ * under the License. */ -package org.elasticsearch.test.unit.index.mapper.geopoint; +package org.elasticsearch.test.unit.index.mapper.geo; import org.elasticsearch.ElasticSearchIllegalArgumentException; import org.elasticsearch.common.Numbers; diff --git a/src/test/java/org/elasticsearch/test/unit/index/query/SimpleIndexQueryParserTests.java b/src/test/java/org/elasticsearch/test/unit/index/query/SimpleIndexQueryParserTests.java index 3e5844bc969..313821f7dfe 100644 --- a/src/test/java/org/elasticsearch/test/unit/index/query/SimpleIndexQueryParserTests.java +++ b/src/test/java/org/elasticsearch/test/unit/index/query/SimpleIndexQueryParserTests.java @@ -1822,4 +1822,27 @@ public class SimpleIndexQueryParserTests { assertThat(filter.points()[2].lat, closeTo(20, 0.00001)); assertThat(filter.points()[2].lon, closeTo(-90, 0.00001)); } + + @Test + public void testGeoShapeFilter() throws IOException { + IndexQueryParserService queryParser = queryParser(); + String query = copyToStringFromClasspath("/org/elasticsearch/test/unit/index/query/geoShape-filter.json"); + Query parsedQuery = queryParser.parse(query).query(); + assertThat(parsedQuery, instanceOf(DeletionAwareConstantScoreQuery.class)); + DeletionAwareConstantScoreQuery constantScoreQuery = (DeletionAwareConstantScoreQuery) parsedQuery; + XTermsFilter filter = (XTermsFilter) constantScoreQuery.getFilter(); + Term exampleTerm = filter.getTerms()[0]; + assertThat(exampleTerm.field(), equalTo("country")); + } + + @Test + public void testGeoShapeQuery() throws IOException { + IndexQueryParserService queryParser = queryParser(); + String query = copyToStringFromClasspath("/org/elasticsearch/test/unit/index/query/geoShape-query.json"); + Query parsedQuery = queryParser.parse(query).query(); + assertThat(parsedQuery, instanceOf(BooleanQuery.class)); + BooleanQuery booleanQuery = (BooleanQuery) parsedQuery; + TermQuery termQuery = (TermQuery) booleanQuery.getClauses()[0].getQuery(); + assertThat(termQuery.getTerm().field(), equalTo("country")); + } } diff --git a/src/test/java/org/elasticsearch/test/unit/index/query/geoShape-filter.json b/src/test/java/org/elasticsearch/test/unit/index/query/geoShape-filter.json new file mode 100644 index 00000000000..a4392ae3465 --- /dev/null +++ b/src/test/java/org/elasticsearch/test/unit/index/query/geoShape-filter.json @@ -0,0 +1,21 @@ +{ + "filtered" : { + "query" : { + "match_all" : {} + }, + "filter" : { + "geo_shape" : { + "country" : { + "shape" : { + "type" : "Envelope", + "coordinates" : [ + [-45, 45], + [45, -45] + ] + }, + "relation" : "intersects" + } + } + } + } +} \ No newline at end of file diff --git a/src/test/java/org/elasticsearch/test/unit/index/query/geoShape-query.json b/src/test/java/org/elasticsearch/test/unit/index/query/geoShape-query.json new file mode 100644 index 00000000000..e0af8278a53 --- /dev/null +++ b/src/test/java/org/elasticsearch/test/unit/index/query/geoShape-query.json @@ -0,0 +1,14 @@ +{ + "geo_shape" : { + "country" : { + "shape" : { + "type" : "Envelope", + "coordinates" : [ + [-45, 45], + [45, -45] + ] + }, + "relation" : "intersects" + } + } +} \ No newline at end of file diff --git a/src/test/java/org/elasticsearch/test/unit/index/query/mapping.json b/src/test/java/org/elasticsearch/test/unit/index/query/mapping.json index 8d21cc4409f..2a21760f194 100644 --- a/src/test/java/org/elasticsearch/test/unit/index/query/mapping.json +++ b/src/test/java/org/elasticsearch/test/unit/index/query/mapping.json @@ -3,6 +3,9 @@ "properties":{ "location":{ "type":"geo_point" + }, + "country" : { + "type" : "geo_shape" } } }