From d6ec5f2d8453ea7cb2dd6da650dc12e509e50d06 Mon Sep 17 00:00:00 2001 From: Igor Motov Date: Fri, 26 Apr 2019 07:33:19 -0400 Subject: [PATCH 1/4] Geo: Add GeoJson parser to libs/geo classes Adds GeoJson parser for Geometry classes defined in libs/geo. This should allow us to bypass ShapeBuilders in geosql and is the first step in ShapeBuilder refactoring. The provided functionality should be sufficient for geosql parsing, but additional functionality will be required for complete parity with ShapeBuilder parser - we need to add handling of mapper parameters in WKT parser as well. That will be added in a follow up PR. Relates #40908 --- .../elasticsearch/geo/geometry/Circle.java | 2 +- .../elasticsearch/geo/geometry/Geometry.java | 2 +- .../geo/geometry/GeometryCollection.java | 2 +- .../geo/geometry/GeometryVisitor.java | 22 +- .../org/elasticsearch/geo/geometry/Line.java | 2 +- .../geo/geometry/LinearRing.java | 2 +- .../elasticsearch/geo/geometry/MultiLine.java | 2 +- .../geo/geometry/MultiPoint.java | 2 +- .../geo/geometry/MultiPolygon.java | 2 +- .../org/elasticsearch/geo/geometry/Point.java | 2 +- .../elasticsearch/geo/geometry/Polygon.java | 2 +- .../elasticsearch/geo/geometry/Rectangle.java | 2 +- .../elasticsearch/geo/geometry/ShapeType.java | 6 + .../geo/utils/WellKnownText.java | 4 +- .../geo/geometry/BaseGeometryTestCase.java | 2 +- .../org/elasticsearch/common/geo/GeoJson.java | 612 ++++++++++++++ .../common/geo/GeometryParser.java | 68 ++ .../index/mapper/GeoShapeFieldMapper.java | 2 +- .../index/query/GeoShapeQueryBuilder.java | 2 +- .../common/geo/BaseGeoParsingTestCase.java | 7 + .../common/geo/GeoJsonParserTests.java | 757 ++++++++++++++++++ .../common/geo/GeoJsonSerializationTests.java | 269 +++++++ .../common/geo/GeometryParserTests.java | 156 ++++ 23 files changed, 1902 insertions(+), 27 deletions(-) create mode 100644 server/src/main/java/org/elasticsearch/common/geo/GeoJson.java create mode 100644 server/src/main/java/org/elasticsearch/common/geo/GeometryParser.java create mode 100644 server/src/test/java/org/elasticsearch/common/geo/GeoJsonParserTests.java create mode 100644 server/src/test/java/org/elasticsearch/common/geo/GeoJsonSerializationTests.java create mode 100644 server/src/test/java/org/elasticsearch/common/geo/GeometryParserTests.java diff --git a/libs/geo/src/main/java/org/elasticsearch/geo/geometry/Circle.java b/libs/geo/src/main/java/org/elasticsearch/geo/geometry/Circle.java index 7140540f5c140..cb8e2c4cb33e1 100644 --- a/libs/geo/src/main/java/org/elasticsearch/geo/geometry/Circle.java +++ b/libs/geo/src/main/java/org/elasticsearch/geo/geometry/Circle.java @@ -102,7 +102,7 @@ public int hashCode() { } @Override - public T visit(GeometryVisitor visitor) { + public T visit(GeometryVisitor visitor) throws E { return visitor.visit(this); } diff --git a/libs/geo/src/main/java/org/elasticsearch/geo/geometry/Geometry.java b/libs/geo/src/main/java/org/elasticsearch/geo/geometry/Geometry.java index 9322193326fc5..140dd13427294 100644 --- a/libs/geo/src/main/java/org/elasticsearch/geo/geometry/Geometry.java +++ b/libs/geo/src/main/java/org/elasticsearch/geo/geometry/Geometry.java @@ -26,7 +26,7 @@ public interface Geometry { ShapeType type(); - T visit(GeometryVisitor visitor); + T visit(GeometryVisitor visitor) throws E; boolean isEmpty(); diff --git a/libs/geo/src/main/java/org/elasticsearch/geo/geometry/GeometryCollection.java b/libs/geo/src/main/java/org/elasticsearch/geo/geometry/GeometryCollection.java index 56e59f94983ed..cc27d7c0972dd 100644 --- a/libs/geo/src/main/java/org/elasticsearch/geo/geometry/GeometryCollection.java +++ b/libs/geo/src/main/java/org/elasticsearch/geo/geometry/GeometryCollection.java @@ -57,7 +57,7 @@ public ShapeType type() { } @Override - public T visit(GeometryVisitor visitor) { + public T visit(GeometryVisitor visitor) throws E { return visitor.visit(this); } diff --git a/libs/geo/src/main/java/org/elasticsearch/geo/geometry/GeometryVisitor.java b/libs/geo/src/main/java/org/elasticsearch/geo/geometry/GeometryVisitor.java index 8317b23d1feca..f4c189fbe288f 100644 --- a/libs/geo/src/main/java/org/elasticsearch/geo/geometry/GeometryVisitor.java +++ b/libs/geo/src/main/java/org/elasticsearch/geo/geometry/GeometryVisitor.java @@ -44,26 +44,26 @@ * * @see Visitor Pattern */ -public interface GeometryVisitor { +public interface GeometryVisitor { - T visit(Circle circle); + T visit(Circle circle) throws E; - T visit(GeometryCollection collection); + T visit(GeometryCollection collection) throws E; - T visit(Line line); + T visit(Line line) throws E; - T visit(LinearRing ring); + T visit(LinearRing ring) throws E; - T visit(MultiLine multiLine); + T visit(MultiLine multiLine) throws E; - T visit(MultiPoint multiPoint); + T visit(MultiPoint multiPoint) throws E; - T visit(MultiPolygon multiPolygon); + T visit(MultiPolygon multiPolygon) throws E; - T visit(Point point); + T visit(Point point) throws E; - T visit(Polygon polygon); + T visit(Polygon polygon) throws E; - T visit(Rectangle rectangle); + T visit(Rectangle rectangle) throws E; } diff --git a/libs/geo/src/main/java/org/elasticsearch/geo/geometry/Line.java b/libs/geo/src/main/java/org/elasticsearch/geo/geometry/Line.java index e06ccc555aa2c..c2c9cb4b83a18 100644 --- a/libs/geo/src/main/java/org/elasticsearch/geo/geometry/Line.java +++ b/libs/geo/src/main/java/org/elasticsearch/geo/geometry/Line.java @@ -103,7 +103,7 @@ public ShapeType type() { } @Override - public T visit(GeometryVisitor visitor) { + public T visit(GeometryVisitor visitor) throws E { return visitor.visit(this); } diff --git a/libs/geo/src/main/java/org/elasticsearch/geo/geometry/LinearRing.java b/libs/geo/src/main/java/org/elasticsearch/geo/geometry/LinearRing.java index 7d66a93ea6d57..d27e512ef34cc 100644 --- a/libs/geo/src/main/java/org/elasticsearch/geo/geometry/LinearRing.java +++ b/libs/geo/src/main/java/org/elasticsearch/geo/geometry/LinearRing.java @@ -54,7 +54,7 @@ public ShapeType type() { } @Override - public T visit(GeometryVisitor visitor) { + public T visit(GeometryVisitor visitor) throws E { return visitor.visit(this); } } diff --git a/libs/geo/src/main/java/org/elasticsearch/geo/geometry/MultiLine.java b/libs/geo/src/main/java/org/elasticsearch/geo/geometry/MultiLine.java index 995c43d0c1c80..ac1f956397bb0 100644 --- a/libs/geo/src/main/java/org/elasticsearch/geo/geometry/MultiLine.java +++ b/libs/geo/src/main/java/org/elasticsearch/geo/geometry/MultiLine.java @@ -40,7 +40,7 @@ public ShapeType type() { } @Override - public T visit(GeometryVisitor visitor) { + public T visit(GeometryVisitor visitor) throws E { return visitor.visit(this); } } diff --git a/libs/geo/src/main/java/org/elasticsearch/geo/geometry/MultiPoint.java b/libs/geo/src/main/java/org/elasticsearch/geo/geometry/MultiPoint.java index 7d57b66ca564f..748902bd9eb72 100644 --- a/libs/geo/src/main/java/org/elasticsearch/geo/geometry/MultiPoint.java +++ b/libs/geo/src/main/java/org/elasticsearch/geo/geometry/MultiPoint.java @@ -40,7 +40,7 @@ public ShapeType type() { } @Override - public T visit(GeometryVisitor visitor) { + public T visit(GeometryVisitor visitor) throws E { return visitor.visit(this); } diff --git a/libs/geo/src/main/java/org/elasticsearch/geo/geometry/MultiPolygon.java b/libs/geo/src/main/java/org/elasticsearch/geo/geometry/MultiPolygon.java index 01c68d6dd0b32..a843d90165b4b 100644 --- a/libs/geo/src/main/java/org/elasticsearch/geo/geometry/MultiPolygon.java +++ b/libs/geo/src/main/java/org/elasticsearch/geo/geometry/MultiPolygon.java @@ -40,7 +40,7 @@ public ShapeType type() { } @Override - public T visit(GeometryVisitor visitor) { + public T visit(GeometryVisitor visitor) throws E { return visitor.visit(this); } } diff --git a/libs/geo/src/main/java/org/elasticsearch/geo/geometry/Point.java b/libs/geo/src/main/java/org/elasticsearch/geo/geometry/Point.java index 189968fdd40b3..248f433b96a13 100644 --- a/libs/geo/src/main/java/org/elasticsearch/geo/geometry/Point.java +++ b/libs/geo/src/main/java/org/elasticsearch/geo/geometry/Point.java @@ -93,7 +93,7 @@ public int hashCode() { } @Override - public T visit(GeometryVisitor visitor) { + public T visit(GeometryVisitor visitor) throws E { return visitor.visit(this); } diff --git a/libs/geo/src/main/java/org/elasticsearch/geo/geometry/Polygon.java b/libs/geo/src/main/java/org/elasticsearch/geo/geometry/Polygon.java index 1dee1c69fc840..ec6f564774ca9 100644 --- a/libs/geo/src/main/java/org/elasticsearch/geo/geometry/Polygon.java +++ b/libs/geo/src/main/java/org/elasticsearch/geo/geometry/Polygon.java @@ -92,7 +92,7 @@ public LinearRing getHole(int i) { } @Override - public T visit(GeometryVisitor visitor) { + public T visit(GeometryVisitor visitor) throws E { return visitor.visit(this); } diff --git a/libs/geo/src/main/java/org/elasticsearch/geo/geometry/Rectangle.java b/libs/geo/src/main/java/org/elasticsearch/geo/geometry/Rectangle.java index 120bf9e2eb862..ca7ec2e57c98d 100644 --- a/libs/geo/src/main/java/org/elasticsearch/geo/geometry/Rectangle.java +++ b/libs/geo/src/main/java/org/elasticsearch/geo/geometry/Rectangle.java @@ -207,7 +207,7 @@ public int hashCode() { } @Override - public T visit(GeometryVisitor visitor) { + public T visit(GeometryVisitor visitor) throws E { return visitor.visit(this); } diff --git a/libs/geo/src/main/java/org/elasticsearch/geo/geometry/ShapeType.java b/libs/geo/src/main/java/org/elasticsearch/geo/geometry/ShapeType.java index 2272f1ad89410..3f6933c688462 100644 --- a/libs/geo/src/main/java/org/elasticsearch/geo/geometry/ShapeType.java +++ b/libs/geo/src/main/java/org/elasticsearch/geo/geometry/ShapeType.java @@ -19,6 +19,8 @@ package org.elasticsearch.geo.geometry; +import java.util.Locale; + /** * Shape types supported by elasticsearch */ @@ -33,4 +35,8 @@ public enum ShapeType { LINEARRING, // not serialized by itself in WKT or WKB ENVELOPE, // not part of the actual WKB spec CIRCLE; // not part of the actual WKB spec + + public static ShapeType forName(String geoshapename) { + return ShapeType.valueOf(geoshapename.toUpperCase(Locale.ROOT)); + } } diff --git a/libs/geo/src/main/java/org/elasticsearch/geo/utils/WellKnownText.java b/libs/geo/src/main/java/org/elasticsearch/geo/utils/WellKnownText.java index ac0b8d9abfea4..e1af54e3383e0 100644 --- a/libs/geo/src/main/java/org/elasticsearch/geo/utils/WellKnownText.java +++ b/libs/geo/src/main/java/org/elasticsearch/geo/utils/WellKnownText.java @@ -68,7 +68,7 @@ public static void toWKT(Geometry geometry, StringBuilder sb) { if (geometry.isEmpty()) { sb.append(EMPTY); } else { - geometry.visit(new GeometryVisitor() { + geometry.visit(new GeometryVisitor() { @Override public Void visit(Circle circle) { sb.append(LPAREN); @@ -543,7 +543,7 @@ private static String nextCloserOrComma(StreamTokenizer stream) throws IOExcepti } public static String getWKTName(Geometry geometry) { - return geometry.visit(new GeometryVisitor() { + return geometry.visit(new GeometryVisitor() { @Override public String visit(Circle circle) { return "circle"; diff --git a/libs/geo/src/test/java/org/elasticsearch/geo/geometry/BaseGeometryTestCase.java b/libs/geo/src/test/java/org/elasticsearch/geo/geometry/BaseGeometryTestCase.java index b3cc834faea24..cc7dcd340c734 100644 --- a/libs/geo/src/test/java/org/elasticsearch/geo/geometry/BaseGeometryTestCase.java +++ b/libs/geo/src/test/java/org/elasticsearch/geo/geometry/BaseGeometryTestCase.java @@ -67,7 +67,7 @@ public void testVisitor() { public static void testVisitor(Geometry geom) { AtomicBoolean called = new AtomicBoolean(false); - Object result = geom.visit(new GeometryVisitor() { + Object result = geom.visit(new GeometryVisitor() { private Object verify(Geometry geometry, String expectedClass) { assertFalse("Visitor should be called only once", called.getAndSet(true)); assertSame(geom, geometry); diff --git a/server/src/main/java/org/elasticsearch/common/geo/GeoJson.java b/server/src/main/java/org/elasticsearch/common/geo/GeoJson.java new file mode 100644 index 0000000000000..c150231ad9097 --- /dev/null +++ b/server/src/main/java/org/elasticsearch/common/geo/GeoJson.java @@ -0,0 +1,612 @@ +/* + * Licensed to Elasticsearch under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch 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.geo; + +import org.elasticsearch.ElasticsearchException; +import org.elasticsearch.ElasticsearchParseException; +import org.elasticsearch.common.ParseField; +import org.elasticsearch.common.geo.parsers.ShapeParser; +import org.elasticsearch.common.unit.DistanceUnit; +import org.elasticsearch.common.xcontent.ConstructingObjectParser; +import org.elasticsearch.common.xcontent.ObjectParser; +import org.elasticsearch.common.xcontent.ToXContent; +import org.elasticsearch.common.xcontent.ToXContentObject; +import org.elasticsearch.common.xcontent.XContentBuilder; +import org.elasticsearch.common.xcontent.XContentParser; +import org.elasticsearch.common.xcontent.XContentSubParser; +import org.elasticsearch.geo.geometry.Circle; +import org.elasticsearch.geo.geometry.Geometry; +import org.elasticsearch.geo.geometry.GeometryCollection; +import org.elasticsearch.geo.geometry.GeometryVisitor; +import org.elasticsearch.geo.geometry.Line; +import org.elasticsearch.geo.geometry.LinearRing; +import org.elasticsearch.geo.geometry.MultiLine; +import org.elasticsearch.geo.geometry.MultiPoint; +import org.elasticsearch.geo.geometry.MultiPolygon; +import org.elasticsearch.geo.geometry.Point; +import org.elasticsearch.geo.geometry.Polygon; +import org.elasticsearch.geo.geometry.Rectangle; +import org.elasticsearch.geo.geometry.ShapeType; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +import static org.elasticsearch.common.xcontent.ConstructingObjectParser.constructorArg; +import static org.elasticsearch.common.xcontent.ConstructingObjectParser.optionalConstructorArg; + +/** + * Utility class for converting libs/geo shapes to and from GeoJson + */ +public final class GeoJson { + + private static final ParseField FIELD_TYPE = new ParseField("type"); + private static final ParseField FIELD_COORDINATES = new ParseField("coordinates"); + private static final ParseField FIELD_GEOMETRIES = new ParseField("geometries"); + private static final ParseField FIELD_ORIENTATION = new ParseField("orientation"); + private static final ParseField FIELD_RADIUS = new ParseField("radius"); + + private GeoJson() { + + } + + public static Geometry fromXContent(XContentParser parser, boolean rightOrientation, boolean coerce, boolean ignoreZValue) + throws IOException { + try (XContentSubParser subParser = new XContentSubParser(parser)) { + return PARSER.apply(subParser, new ParserContext(rightOrientation, coerce, ignoreZValue)); + } + } + + public static XContentBuilder toXContent(Geometry geometry, XContentBuilder builder, ToXContent.Params params) throws IOException { + builder.startObject(); + builder.field(FIELD_TYPE.getPreferredName(), getGeoJsonName(geometry)); + geometry.visit(new GeometryVisitor() { + @Override + public XContentBuilder visit(Circle circle) throws IOException { + builder.field(FIELD_RADIUS.getPreferredName(), DistanceUnit.METERS.toString(circle.getRadiusMeters())); + builder.field(ShapeParser.FIELD_COORDINATES.getPreferredName()); + return coordinatesToXContent(circle.getLat(), circle.getLon(), circle.getAlt()); + } + + @Override + public XContentBuilder visit(GeometryCollection collection) throws IOException { + builder.startArray(FIELD_GEOMETRIES.getPreferredName()); + for (Geometry g : collection) { + toXContent(g, builder, params); + } + return builder.endArray(); + } + + @Override + public XContentBuilder visit(Line line) throws IOException { + builder.field(ShapeParser.FIELD_COORDINATES.getPreferredName()); + return coordinatesToXContent(line); + } + + @Override + public XContentBuilder visit(LinearRing ring) { + throw new UnsupportedOperationException("line ring cannot be serialized using GeoJson"); + } + + @Override + public XContentBuilder visit(MultiLine multiLine) throws IOException { + builder.field(ShapeParser.FIELD_COORDINATES.getPreferredName()); + builder.startArray(); + for (int i = 0; i < multiLine.size(); i++) { + coordinatesToXContent(multiLine.get(i)); + } + return builder.endArray(); + } + + @Override + public XContentBuilder visit(MultiPoint multiPoint) throws IOException { + builder.startArray(ShapeParser.FIELD_COORDINATES.getPreferredName()); + for (int i = 0; i < multiPoint.size(); i++) { + Point p = multiPoint.get(i); + builder.startArray().value(p.getLon()).value(p.getLat()); + if (p.hasAlt()) { + builder.value(p.getAlt()); + } + builder.endArray(); + } + return builder.endArray(); + } + + @Override + public XContentBuilder visit(MultiPolygon multiPolygon) throws IOException { + builder.startArray(ShapeParser.FIELD_COORDINATES.getPreferredName()); + for (int i = 0; i < multiPolygon.size(); i++) { + builder.startArray(); + coordinatesToXContent(multiPolygon.get(i)); + builder.endArray(); + } + return builder.endArray(); + } + + @Override + public XContentBuilder visit(Point point) throws IOException { + builder.field(ShapeParser.FIELD_COORDINATES.getPreferredName()); + return coordinatesToXContent(point.getLat(), point.getLon(), point.getAlt()); + } + + @Override + public XContentBuilder visit(Polygon polygon) throws IOException { + builder.startArray(ShapeParser.FIELD_COORDINATES.getPreferredName()); + coordinatesToXContent(polygon.getPolygon()); + for (int i = 0; i < polygon.getNumberOfHoles(); i++) { + coordinatesToXContent(polygon.getHole(i)); + } + return builder.endArray(); + } + + @Override + public XContentBuilder visit(Rectangle rectangle) throws IOException { + builder.startArray(ShapeParser.FIELD_COORDINATES.getPreferredName()); + coordinatesToXContent(rectangle.getMaxLat(), rectangle.getMinLon(), rectangle.getMinAlt()); // top left + coordinatesToXContent(rectangle.getMinLat(), rectangle.getMaxLon(), rectangle.getMaxAlt()); // bottom right + return builder.endArray(); + } + + private XContentBuilder coordinatesToXContent(double lat, double lon, double alt) throws IOException { + builder.startArray().value(lon).value(lat); + if (Double.isNaN(alt) == false) { + builder.value(alt); + } + return builder.endArray(); + } + + private XContentBuilder coordinatesToXContent(Line line) throws IOException { + builder.startArray(); + for (int i = 0; i < line.length(); i++) { + builder.startArray().value(line.getLon(i)).value(line.getLat(i)); + if (line.hasAlt()) { + builder.value(line.getAlt(i)); + } + builder.endArray(); + } + return builder.endArray(); + } + + private XContentBuilder coordinatesToXContent(Polygon polygon) throws IOException { + coordinatesToXContent(polygon.getPolygon()); + for (int i = 0; i < polygon.getNumberOfHoles(); i++) { + coordinatesToXContent(polygon.getHole(i)); + } + return builder; + } + + }); + return builder.endObject(); + } + + private static class ParserContext { + public final boolean defaultOrientation; + public final boolean coerce; + public final boolean ignoreZValue; + + ParserContext(boolean defaultOrientation, boolean coerce, boolean ignoreZValue) { + this.defaultOrientation = defaultOrientation; + this.coerce = coerce; + this.ignoreZValue = ignoreZValue; + } + } + + private static ConstructingObjectParser PARSER = + new ConstructingObjectParser<>("geojson", true, (a, c) -> { + String type = (String) a[0]; + CoordinateNode coordinates = (CoordinateNode) a[1]; + @SuppressWarnings("unchecked") List geometries = (List) a[2]; + Boolean orientation = orientationFromString((String) a[3]); + DistanceUnit.Distance radius = (DistanceUnit.Distance) a[4]; + return createGeometry(type, geometries, coordinates, orientation, c.defaultOrientation, c.coerce, radius); + }); + + static { + PARSER.declareString(constructorArg(), FIELD_TYPE); + PARSER.declareField(optionalConstructorArg(), (p, c) -> parseCoordinates(p, c.ignoreZValue), FIELD_COORDINATES, + ObjectParser.ValueType.VALUE_ARRAY); + PARSER.declareObjectArray(optionalConstructorArg(), PARSER, FIELD_GEOMETRIES); + PARSER.declareString(optionalConstructorArg(), FIELD_ORIENTATION); + PARSER.declareField(optionalConstructorArg(), p -> DistanceUnit.Distance.parseDistance(p.text()), FIELD_RADIUS, + ObjectParser.ValueType.STRING); + } + + private static Geometry createGeometry(String type, List geometries, CoordinateNode coordinates, Boolean orientation, + boolean defaultOrientation, boolean coerce, DistanceUnit.Distance radius) { + + ShapeType shapeType = ShapeType.forName(type); + if (shapeType == ShapeType.GEOMETRYCOLLECTION) { + if (geometries == null) { + throw new ElasticsearchParseException("geometries not included"); + } + if (coordinates != null) { + throw new ElasticsearchParseException("parameter coordinates is not supported for type " + type); + } + verifyNulls(type, null, orientation, radius); + return new GeometryCollection<>(geometries); + } + + // We expect to have coordinates for all the rest + if (coordinates == null) { + throw new ElasticsearchParseException("coordinates not included"); + } + + switch (shapeType) { + case CIRCLE: + if (radius == null) { + throw new ElasticsearchParseException("radius is not specified"); + } + verifyNulls(type, geometries, orientation, null); + Point point = coordinates.asPoint(); + return new Circle(point.getLat(), point.getLon(), point.getAlt(), radius.convert(DistanceUnit.METERS).value); + case POINT: + verifyNulls(type, geometries, orientation, radius); + return coordinates.asPoint(); + case MULTIPOINT: + verifyNulls(type, geometries, orientation, radius); + return coordinates.asMultiPoint(); + case LINESTRING: + verifyNulls(type, geometries, orientation, radius); + return coordinates.asLineString(coerce); + case MULTILINESTRING: + verifyNulls(type, geometries, orientation, radius); + return coordinates.asMultiLineString(coerce); + case POLYGON: + verifyNulls(type, geometries, null, radius); + // handle possible null in orientation + return coordinates.asPolygon(orientation != null ? orientation : defaultOrientation, coerce); + case MULTIPOLYGON: + verifyNulls(type, geometries, null, radius); + // handle possible null in orientation + return coordinates.asMultiPolygon(orientation != null ? orientation : defaultOrientation, coerce); + case ENVELOPE: + verifyNulls(type, geometries, orientation, radius); + return coordinates.asRectangle(); + default: + throw new ElasticsearchParseException("unsuppoted shape type " + type); + } + } + + /** + * Checks that all passed parameters except type are null, generates corresponding error messages if they are not + */ + private static void verifyNulls(String type, List geometries, Boolean orientation, DistanceUnit.Distance radius) { + if (geometries != null) { + throw new ElasticsearchParseException("parameter geometries is not supported for type " + type); + } + if (orientation != null) { + throw new ElasticsearchParseException("parameter orientation is not supported for type " + type); + } + if (radius != null) { + throw new ElasticsearchParseException("parameter radius is not supported for type " + type); + } + } + + /** + * Recursive method which parses the arrays of coordinates used to define + * Shapes + */ + private static CoordinateNode parseCoordinates(XContentParser parser, boolean ignoreZValue) throws IOException { + XContentParser.Token token = parser.nextToken(); + // Base cases + if (token != XContentParser.Token.START_ARRAY && + token != XContentParser.Token.END_ARRAY && + token != XContentParser.Token.VALUE_NULL) { + return new CoordinateNode(parseCoordinate(parser, ignoreZValue)); + } else if (token == XContentParser.Token.VALUE_NULL) { + throw new IllegalArgumentException("coordinates cannot contain NULL values)"); + } + + List nodes = new ArrayList<>(); + while (token != XContentParser.Token.END_ARRAY) { + CoordinateNode node = parseCoordinates(parser, ignoreZValue); + if (nodes.isEmpty() == false && nodes.get(0).numDimensions() != node.numDimensions()) { + throw new ElasticsearchParseException("Exception parsing coordinates: number of dimensions do not match"); + } + nodes.add(node); + token = parser.nextToken(); + } + + return new CoordinateNode(nodes); + } + + /** + * Parser a singe set of 2 or 3 coordinates + */ + private static Point parseCoordinate(XContentParser parser, boolean ignoreZValue) throws IOException { + // Add support for coerce here + if (parser.currentToken() != XContentParser.Token.VALUE_NUMBER) { + throw new ElasticsearchParseException("geo coordinates must be numbers"); + } + double lon = parser.doubleValue(); + if (parser.nextToken() != XContentParser.Token.VALUE_NUMBER) { + throw new ElasticsearchParseException("geo coordinates must be numbers"); + } + double lat = parser.doubleValue(); + XContentParser.Token token = parser.nextToken(); + // alt (for storing purposes only - future use includes 3d shapes) + double alt = Double.NaN; + if (token == XContentParser.Token.VALUE_NUMBER) { + alt = GeoPoint.assertZValue(ignoreZValue, parser.doubleValue()); + parser.nextToken(); + } + // do not support > 3 dimensions + if (parser.currentToken() == XContentParser.Token.VALUE_NUMBER) { + throw new ElasticsearchParseException("geo coordinates greater than 3 dimensions are not supported"); + } + return new Point(lat, lon, alt); + } + + /** + * Returns true for right orientation and false for left + */ + private static Boolean orientationFromString(String orientation) { + if (orientation == null) { + return null; + } + orientation = orientation.toLowerCase(Locale.ROOT); + switch (orientation) { + case "right": + case "counterclockwise": + case "ccw": + return true; + case "left": + case "clockwise": + case "cw": + return false; + default: + throw new IllegalArgumentException("Unknown orientation [" + orientation + "]"); + } + } + + public static String getGeoJsonName(Geometry geometry) { + return geometry.visit(new GeometryVisitor<>() { + @Override + public String visit(Circle circle) { + return "Circle"; + } + + @Override + public String visit(GeometryCollection collection) { + return "GeometryCollection"; + } + + @Override + public String visit(Line line) { + return "LineString"; + } + + @Override + public String visit(LinearRing ring) { + throw new UnsupportedOperationException("line ring cannot be serialized using GeoJson"); + } + + @Override + public String visit(MultiLine multiLine) { + return "MultiLineString"; + } + + @Override + public String visit(MultiPoint multiPoint) { + return "MultiPoint"; + } + + @Override + public String visit(MultiPolygon multiPolygon) { + return "MultiPolygon"; + } + + @Override + public String visit(Point point) { + return "Point"; + } + + @Override + public String visit(Polygon polygon) { + return "Polygon"; + } + + @Override + public String visit(Rectangle rectangle) { + return "Envelope"; + } + }); + } + + private static class CoordinateNode implements ToXContentObject { + public final Point coordinate; + public final List children; + + /** + * Creates a new leaf CoordinateNode + * + * @param coordinate Coordinate for the Node + */ + CoordinateNode(Point coordinate) { + this.coordinate = coordinate; + this.children = null; + } + + /** + * Creates a new parent CoordinateNode + * + * @param children Children of the Node + */ + CoordinateNode(List children) { + this.children = children; + this.coordinate = null; + } + + public boolean isEmpty() { + return (coordinate == null && (children == null || children.isEmpty())); + } + + protected int numDimensions() { + if (isEmpty()) { + throw new ElasticsearchException("attempting to get number of dimensions on an empty coordinate node"); + } + if (coordinate != null) { + return coordinate.hasAlt() ? 3 : 2; + } + return children.get(0).numDimensions(); + } + + public Point asPoint() { + if (children != null) { + throw new ElasticsearchException("expected a single points but got a list"); + } + return coordinate; + } + + public MultiPoint asMultiPoint() { + if (coordinate != null) { + throw new ElasticsearchException("expected a list of points but got a point"); + } + List points = new ArrayList<>(); + for (CoordinateNode node : children) { + points.add(node.asPoint()); + } + return new MultiPoint(points); + } + + private double[][] asLineComponents(boolean orientation, boolean coerce) { + if (coordinate != null) { + throw new ElasticsearchException("expected a list of points but got a point"); + } + + if (children.size() < 2) { + throw new ElasticsearchException("not enough points to build a line"); + } + + boolean needsClosing; + int resultSize; + if (coerce && children.get(0).asPoint().equals(children.get(children.size() - 1).asPoint()) == false) { + needsClosing = true; + resultSize = children.size() + 1; + } else { + needsClosing = false; + resultSize = children.size(); + } + + double[] lats = new double[resultSize]; + double[] lons = new double[resultSize]; + double[] alts = numDimensions() == 3 ? new double[resultSize] : null; + int i = orientation ? 0 : lats.length - 1; + for (CoordinateNode node : children) { + Point point = node.asPoint(); + lats[i] = point.getLat(); + lons[i] = point.getLon(); + if (alts != null) { + alts[i] = point.getAlt(); + } + i = orientation ? i + 1 : i - 1; + } + if (needsClosing) { + lats[resultSize - 1] = lats[0]; + lons[resultSize - 1] = lons[0]; + if (alts != null) { + alts[resultSize - 1] = alts[0]; + } + } + double[][] components = new double[3][]; + components[0] = lats; + components[1] = lons; + components[2] = alts; + return components; + } + + public Line asLineString(boolean coerce) { + double[][] components = asLineComponents(true, coerce); + return new Line(components[0], components[1], components[2]); + } + + public LinearRing asLinearRing(boolean orientation, boolean coerce) { + double[][] components = asLineComponents(orientation, coerce); + return new LinearRing(components[0], components[1], components[2]); + } + + public MultiLine asMultiLineString(boolean coerce) { + if (coordinate != null) { + throw new ElasticsearchException("expected a list of points but got a point"); + } + List lines = new ArrayList<>(); + for (CoordinateNode node : children) { + lines.add(node.asLineString(coerce)); + } + return new MultiLine(lines); + } + + + public Polygon asPolygon(boolean orientation, boolean coerce) { + if (coordinate != null) { + throw new ElasticsearchException("expected a list of points but got a point"); + } + List lines = new ArrayList<>(); + for (CoordinateNode node : children) { + lines.add(node.asLinearRing(orientation, coerce)); + } + if (lines.size() == 1) { + return new Polygon(lines.get(0)); + } else { + LinearRing shell = lines.remove(0); + return new Polygon(shell, lines); + } + } + + public MultiPolygon asMultiPolygon(boolean orientation, boolean coerce) { + if (coordinate != null) { + throw new ElasticsearchException("expected a list of points but got a point"); + } + List polygons = new ArrayList<>(); + for (CoordinateNode node : children) { + polygons.add(node.asPolygon(orientation, coerce)); + } + return new MultiPolygon(polygons); + } + + public Rectangle asRectangle() { + if (children.size() != 2) { + throw new ElasticsearchParseException( + "invalid number of points [{}] provided for geo_shape [{}] when expecting an array of 2 coordinates", + children.size(), ShapeType.ENVELOPE); + } + // verify coordinate bounds, correct if necessary + Point uL = children.get(0).coordinate; + Point lR = children.get(1).coordinate; + return new Rectangle(lR.getLat(), uL.getLat(), uL.getLon(), lR.getLon()); + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + if (children == null) { + builder.startArray().value(coordinate.getLon()).value(coordinate.getLat()).endArray(); + } else { + builder.startArray(); + for (CoordinateNode child : children) { + child.toXContent(builder, params); + } + builder.endArray(); + } + return builder; + } + } + +} diff --git a/server/src/main/java/org/elasticsearch/common/geo/GeometryParser.java b/server/src/main/java/org/elasticsearch/common/geo/GeometryParser.java new file mode 100644 index 0000000000000..5bb0678542a5d --- /dev/null +++ b/server/src/main/java/org/elasticsearch/common/geo/GeometryParser.java @@ -0,0 +1,68 @@ +/* + * Licensed to Elasticsearch under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch 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.geo; + +import org.elasticsearch.ElasticsearchParseException; +import org.elasticsearch.common.geo.builders.ShapeBuilder; +import org.elasticsearch.common.xcontent.XContentParser; +import org.elasticsearch.geo.geometry.Geometry; +import org.elasticsearch.geo.utils.WellKnownText; +import org.elasticsearch.index.mapper.BaseGeoShapeFieldMapper; + +import java.io.IOException; +import java.text.ParseException; + +/** + * An utility class with a geometry parser methods supporting different shape representation formats + */ +public final class GeometryParser { + + private GeometryParser() { + + } + + /** + * Parses supplied XContent into Geometry + */ + static Geometry parse(XContentParser parser, BaseGeoShapeFieldMapper shapeMapper) throws IOException, ParseException { + if (parser.currentToken() == XContentParser.Token.VALUE_NULL) { + return null; + } else if (parser.currentToken() == XContentParser.Token.START_OBJECT) { + boolean orientation; + boolean coerce; + boolean ignoreZValue; + + if (shapeMapper == null) { + orientation = true; + coerce = true; + ignoreZValue = true; + } else { + orientation = shapeMapper.orientation() == ShapeBuilder.Orientation.RIGHT; + coerce = shapeMapper.coerce().value(); + ignoreZValue = shapeMapper.ignoreZValue().value(); + } + return GeoJson.fromXContent(parser, orientation, coerce, ignoreZValue); + } else if (parser.currentToken() == XContentParser.Token.VALUE_STRING) { + // TODO: Add support for ignoreZValue and coerce to WKT + return WellKnownText.fromWKT(parser.text()); + } + throw new ElasticsearchParseException("shape must be an object consisting of type and coordinates"); + } +} diff --git a/server/src/main/java/org/elasticsearch/index/mapper/GeoShapeFieldMapper.java b/server/src/main/java/org/elasticsearch/index/mapper/GeoShapeFieldMapper.java index 72b3e68fa025e..6449c06fbe1ad 100644 --- a/server/src/main/java/org/elasticsearch/index/mapper/GeoShapeFieldMapper.java +++ b/server/src/main/java/org/elasticsearch/index/mapper/GeoShapeFieldMapper.java @@ -134,7 +134,7 @@ private void indexShape(ParseContext context, Object luceneShape) { } } - private class LuceneGeometryIndexer implements GeometryVisitor { + private class LuceneGeometryIndexer implements GeometryVisitor { private ParseContext context; private LuceneGeometryIndexer(ParseContext context) { diff --git a/server/src/main/java/org/elasticsearch/index/query/GeoShapeQueryBuilder.java b/server/src/main/java/org/elasticsearch/index/query/GeoShapeQueryBuilder.java index b13666296b55d..b651a26d7e280 100644 --- a/server/src/main/java/org/elasticsearch/index/query/GeoShapeQueryBuilder.java +++ b/server/src/main/java/org/elasticsearch/index/query/GeoShapeQueryBuilder.java @@ -450,7 +450,7 @@ private Query getVectorQuery(QueryShardContext context, ShapeBuilder queryShapeB } private Query getVectorQueryFromShape(QueryShardContext context, Geometry queryShape) { - return queryShape.visit(new GeometryVisitor() { + return queryShape.visit(new GeometryVisitor() { @Override public Query visit(Circle circle) { throw new QueryShardException(context, "Field [" + fieldName + "] found and unknown shape Circle"); diff --git a/server/src/test/java/org/elasticsearch/common/geo/BaseGeoParsingTestCase.java b/server/src/test/java/org/elasticsearch/common/geo/BaseGeoParsingTestCase.java index 9359101128883..7f6c56855ec70 100644 --- a/server/src/test/java/org/elasticsearch/common/geo/BaseGeoParsingTestCase.java +++ b/server/src/test/java/org/elasticsearch/common/geo/BaseGeoParsingTestCase.java @@ -67,6 +67,13 @@ protected void assertGeometryEquals(Object expected, XContentBuilder geoJson, bo } } + protected void assertGeometryEquals(org.elasticsearch.geo.geometry.Geometry expected, XContentBuilder geoJson) throws IOException { + try (XContentParser parser = createParser(geoJson)) { + parser.nextToken(); + assertEquals(expected, GeoJson.fromXContent(parser, true, false, false)); + } + } + protected ShapeCollection shapeCollection(Shape... shapes) { return new ShapeCollection<>(Arrays.asList(shapes), SPATIAL_CONTEXT); } diff --git a/server/src/test/java/org/elasticsearch/common/geo/GeoJsonParserTests.java b/server/src/test/java/org/elasticsearch/common/geo/GeoJsonParserTests.java new file mode 100644 index 0000000000000..4e2c2e50e2cf1 --- /dev/null +++ b/server/src/test/java/org/elasticsearch/common/geo/GeoJsonParserTests.java @@ -0,0 +1,757 @@ +/* + * Licensed to Elasticsearch under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch 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.geo; + +import org.elasticsearch.common.Strings; +import org.elasticsearch.common.xcontent.XContentBuilder; +import org.elasticsearch.common.xcontent.XContentFactory; +import org.elasticsearch.common.xcontent.XContentParseException; +import org.elasticsearch.common.xcontent.XContentParser; +import org.elasticsearch.common.xcontent.json.JsonXContent; +import org.elasticsearch.geo.geometry.Circle; +import org.elasticsearch.geo.geometry.Geometry; +import org.elasticsearch.geo.geometry.GeometryCollection; +import org.elasticsearch.geo.geometry.Line; +import org.elasticsearch.geo.geometry.LinearRing; +import org.elasticsearch.geo.geometry.MultiLine; +import org.elasticsearch.geo.geometry.MultiPoint; +import org.elasticsearch.geo.geometry.MultiPolygon; +import org.elasticsearch.geo.geometry.Point; +import org.elasticsearch.geo.geometry.Polygon; +import org.elasticsearch.geo.geometry.Rectangle; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Collections; + + +/** + * Tests for {@code GeoJSONShapeParser} + */ +public class GeoJsonParserTests extends BaseGeoParsingTestCase { + + @Override + public void testParsePoint() throws IOException { + XContentBuilder pointGeoJson = XContentFactory.jsonBuilder() + .startObject() + .field("type", "Point") + .startArray("coordinates").value(100.0).value(0.0).endArray() + .endObject(); + Point expected = new Point(0.0, 100.0); + assertGeometryEquals(expected, pointGeoJson); + } + + @Override + public void testParseLineString() throws IOException { + XContentBuilder 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(); + + Line expected = new Line(new double[] {0.0, 1.0}, new double[] { 100.0, 101.0}); + try (XContentParser parser = createParser(lineGeoJson)) { + parser.nextToken(); + assertEquals(expected, GeoJson.fromXContent(parser, false, false, true)); + } + } + + @Override + public void testParseMultiLineString() throws IOException { + XContentBuilder multilinesGeoJson = XContentFactory.jsonBuilder() + .startObject() + .field("type", "MultiLineString") + .startArray("coordinates") + .startArray() + .startArray().value(100.0).value(0.0).endArray() + .startArray().value(101.0).value(1.0).endArray() + .endArray() + .startArray() + .startArray().value(102.0).value(2.0).endArray() + .startArray().value(103.0).value(3.0).endArray() + .endArray() + .endArray() + .endObject(); + + MultiLine expected = new MultiLine(Arrays.asList( + new Line(new double[] {0.0, 1.0}, new double[] { 100.0, 101.0}), + new Line(new double[] {2.0, 3.0}, new double[] { 102.0, 103.0}) + + )); + + assertGeometryEquals(expected, multilinesGeoJson); + } + + public void testParseCircle() throws IOException { + XContentBuilder multilinesGeoJson = XContentFactory.jsonBuilder() + .startObject() + .field("type", "circle") + .startArray("coordinates").value(100.0).value(0.0).endArray() + .field("radius", "200m") + .endObject(); + + Circle expected = new Circle(0.0, 100.0, 200); + assertGeometryEquals(expected, multilinesGeoJson); + } + + public void testParseMultiDimensionShapes() throws IOException { + // multi dimension point + XContentBuilder pointGeoJson = XContentFactory.jsonBuilder() + .startObject() + .field("type", "Point") + .startArray("coordinates").value(100.0).value(0.0).value(15.0).value(18.0).endArray() + .endObject(); + + try (XContentParser parser = createParser(pointGeoJson)) { + parser.nextToken(); + expectThrows(XContentParseException.class, () -> GeoJson.fromXContent(parser, false, false, false)); + assertNull(parser.nextToken()); + } + + // multi dimension linestring + XContentBuilder lineGeoJson = XContentFactory.jsonBuilder() + .startObject() + .field("type", "LineString") + .startArray("coordinates") + .startArray().value(100.0).value(0.0).value(15.0).endArray() + .startArray().value(101.0).value(1.0).value(18.0).value(19.0).endArray() + .endArray() + .endObject(); + + try (XContentParser parser = createParser(lineGeoJson)) { + parser.nextToken(); + expectThrows(XContentParseException.class, () -> GeoJson.fromXContent(parser, false, false, false)); + assertNull(parser.nextToken()); + } + } + + @Override + public void testParseEnvelope() throws IOException { + // test #1: envelope with expected coordinate order (TopLeft, BottomRight) + XContentBuilder multilinesGeoJson = XContentFactory.jsonBuilder().startObject().field("type", "envelope") + .startArray("coordinates") + .startArray().value(-50).value(30).endArray() + .startArray().value(50).value(-30).endArray() + .endArray() + .endObject(); + Rectangle expected = new Rectangle(-30, 30, -50, 50); + assertGeometryEquals(expected, multilinesGeoJson); + + // test #2: envelope that spans dateline + multilinesGeoJson = XContentFactory.jsonBuilder().startObject().field("type", "envelope") + .startArray("coordinates") + .startArray().value(50).value(30).endArray() + .startArray().value(-50).value(-30).endArray() + .endArray() + .endObject(); + + expected = new Rectangle(-30, 30, 50, -50); + assertGeometryEquals(expected, multilinesGeoJson); + + // test #3: "envelope" (actually a triangle) with invalid number of coordinates (TopRight, BottomLeft, BottomRight) + multilinesGeoJson = XContentFactory.jsonBuilder().startObject().field("type", "envelope") + .startArray("coordinates") + .startArray().value(50).value(30).endArray() + .startArray().value(-50).value(-30).endArray() + .startArray().value(50).value(-39).endArray() + .endArray() + .endObject(); + try (XContentParser parser = createParser(multilinesGeoJson)) { + parser.nextToken(); + expectThrows(XContentParseException.class, () -> GeoJson.fromXContent(parser, false, false, false)); + assertNull(parser.nextToken()); + } + + // test #4: "envelope" with empty coordinates + multilinesGeoJson = XContentFactory.jsonBuilder().startObject().field("type", "envelope") + .startArray("coordinates") + .endArray() + .endObject(); + try (XContentParser parser = createParser(multilinesGeoJson)) { + parser.nextToken(); + expectThrows(XContentParseException.class, () -> GeoJson.fromXContent(parser, false, false, false)); + assertNull(parser.nextToken()); + } + } + + @Override + public void testParsePolygon() throws IOException { + XContentBuilder polygonGeoJson = XContentFactory.jsonBuilder() + .startObject() + .field("type", "Polygon") + .startArray("coordinates") + .startArray() + .startArray().value(100.0).value(1.0).endArray() + .startArray().value(101.0).value(1.0).endArray() + .startArray().value(101.0).value(0.0).endArray() + .startArray().value(100.0).value(0.0).endArray() + .startArray().value(100.0).value(1.0).endArray() + .endArray() + .endArray() + .endObject(); + + Polygon p = new Polygon( + new LinearRing( + new double[] {1d, 1d, 0d, 0d, 1d}, + new double[] {100d, 101d, 101d, 100d, 100d})); + assertGeometryEquals(p, polygonGeoJson); + } + + public void testParse3DPolygon() throws IOException { + XContentBuilder polygonGeoJson = XContentFactory.jsonBuilder() + .startObject() + .field("type", "Polygon") + .startArray("coordinates") + .startArray() + .startArray().value(100.0).value(1.0).value(10.0).endArray() + .startArray().value(101.0).value(1.0).value(10.0).endArray() + .startArray().value(101.0).value(0.0).value(10.0).endArray() + .startArray().value(100.0).value(0.0).value(10.0).endArray() + .startArray().value(100.0).value(1.0).value(10.0).endArray() + .endArray() + .endArray() + .endObject(); + + Polygon expected = new Polygon(new LinearRing( + new double[]{1.0, 1.0, 0.0, 0.0, 1.0}, + new double[]{100.0, 101.0, 101.0, 100.0, 100.0}, + new double[]{10.0, 10.0, 10.0, 10.0, 10.0} + )); + try (XContentParser parser = createParser(polygonGeoJson)) { + parser.nextToken(); + assertEquals(expected, GeoJson.fromXContent(parser, true, false, true)); + } + } + + public void testInvalidDimensionalPolygon() throws IOException { + XContentBuilder polygonGeoJson = XContentFactory.jsonBuilder() + .startObject() + .field("type", "Polygon") + .startArray("coordinates") + .startArray() + .startArray().value(100.0).value(1.0).value(10.0).endArray() + .startArray().value(101.0).value(1.0).endArray() + .startArray().value(101.0).value(0.0).value(10.0).endArray() + .startArray().value(100.0).value(0.0).value(10.0).endArray() + .startArray().value(100.0).value(1.0).value(10.0).endArray() + .endArray() + .endArray() + .endObject(); + try (XContentParser parser = createParser(polygonGeoJson)) { + parser.nextToken(); + expectThrows(XContentParseException.class, () -> GeoJson.fromXContent(parser, true, false, true)); + assertNull(parser.nextToken()); + } + } + + public void testParseInvalidPoint() throws IOException { + // test case 1: create an invalid point object with multipoint data format + XContentBuilder invalidPoint1 = XContentFactory.jsonBuilder() + .startObject() + .field("type", "point") + .startArray("coordinates") + .startArray().value(-74.011).value(40.753).endArray() + .endArray() + .endObject(); + try (XContentParser parser = createParser(invalidPoint1)) { + parser.nextToken(); + expectThrows(XContentParseException.class, () -> GeoJson.fromXContent(parser, true, false, false)); + assertNull(parser.nextToken()); + } + + // test case 2: create an invalid point object with an empty number of coordinates + XContentBuilder invalidPoint2 = XContentFactory.jsonBuilder() + .startObject() + .field("type", "point") + .startArray("coordinates") + .endArray() + .endObject(); + try (XContentParser parser = createParser(invalidPoint2)) { + parser.nextToken(); + expectThrows(XContentParseException.class, () -> GeoJson.fromXContent(parser, true, false, false)); + assertNull(parser.nextToken()); + } + } + + public void testParseInvalidMultipoint() throws IOException { + // test case 1: create an invalid multipoint object with single coordinate + XContentBuilder invalidMultipoint1 = XContentFactory.jsonBuilder() + .startObject() + .field("type", "multipoint") + .startArray("coordinates").value(-74.011).value(40.753).endArray() + .endObject(); + try (XContentParser parser = createParser(invalidMultipoint1)) { + parser.nextToken(); + expectThrows(XContentParseException.class, () -> GeoJson.fromXContent(parser, true, false, false)); + assertNull(parser.nextToken()); + } + + // test case 2: create an invalid multipoint object with null coordinate + XContentBuilder invalidMultipoint2 = XContentFactory.jsonBuilder() + .startObject() + .field("type", "multipoint") + .startArray("coordinates") + .endArray() + .endObject(); + try (XContentParser parser = createParser(invalidMultipoint2)) { + parser.nextToken(); + expectThrows(XContentParseException.class, () -> GeoJson.fromXContent(parser, true, false, false)); + assertNull(parser.nextToken()); + } + + // test case 3: create a valid formatted multipoint object with invalid number (0) of coordinates + XContentBuilder invalidMultipoint3 = XContentFactory.jsonBuilder() + .startObject() + .field("type", "multipoint") + .startArray("coordinates") + .startArray().endArray() + .endArray() + .endObject(); + try (XContentParser parser = createParser(invalidMultipoint3)) { + parser.nextToken(); + expectThrows(XContentParseException.class, () -> GeoJson.fromXContent(parser, true, false, false)); + assertNull(parser.nextToken()); + } + } + + public void testParseInvalidDimensionalMultiPolygon() throws IOException { + // test invalid multipolygon (an "accidental" polygon with inner rings outside outer ring) + String multiPolygonGeoJson = Strings.toString(XContentFactory.jsonBuilder() + .startObject() + .field("type", "MultiPolygon") + .startArray("coordinates") + .startArray()//first poly (without holes) + .startArray() + .startArray().value(102.0).value(2.0).endArray() + .startArray().value(103.0).value(2.0).endArray() + .startArray().value(103.0).value(3.0).endArray() + .startArray().value(102.0).value(3.0).endArray() + .startArray().value(102.0).value(2.0).endArray() + .endArray() + .endArray() + .startArray()//second poly (with hole) + .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()//hole + .startArray().value(100.2).value(0.8).endArray() + .startArray().value(100.2).value(0.2).value(10.0).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() + .endArray() + .endArray() + .endArray() + .endObject()); + + try (XContentParser parser = createParser(JsonXContent.jsonXContent, multiPolygonGeoJson)) { + parser.nextToken(); + expectThrows(XContentParseException.class, () -> GeoJson.fromXContent(parser, true, false, false)); + assertNull(parser.nextToken()); + } + } + + public void testParseInvalidPolygon() throws IOException { + /* + * The following 3 test cases ensure proper error handling of invalid polygons + * per the GeoJSON specification + */ + // test case 1: create an invalid polygon with only 2 points + String invalidPoly = Strings.toString(XContentFactory.jsonBuilder().startObject().field("type", "polygon") + .startArray("coordinates") + .startArray() + .startArray().value(-74.011).value(40.753).endArray() + .startArray().value(-75.022).value(41.783).endArray() + .endArray() + .endArray() + .endObject()); + try (XContentParser parser = createParser(JsonXContent.jsonXContent, invalidPoly)) { + parser.nextToken(); + expectThrows(XContentParseException.class, () -> GeoJson.fromXContent(parser, true, false, false)); + assertNull(parser.nextToken()); + } + + // test case 2: create an invalid polygon with only 1 point + invalidPoly = Strings.toString(XContentFactory.jsonBuilder().startObject().field("type", "polygon") + .startArray("coordinates") + .startArray() + .startArray().value(-74.011).value(40.753).endArray() + .endArray() + .endArray() + .endObject()); + + try (XContentParser parser = createParser(JsonXContent.jsonXContent, invalidPoly)) { + parser.nextToken(); + expectThrows(XContentParseException.class, () -> GeoJson.fromXContent(parser, true, false, false)); + assertNull(parser.nextToken()); + } + + // test case 3: create an invalid polygon with 0 points + invalidPoly = Strings.toString(XContentFactory.jsonBuilder().startObject().field("type", "polygon") + .startArray("coordinates") + .startArray() + .startArray().endArray() + .endArray() + .endArray() + .endObject()); + + try (XContentParser parser = createParser(JsonXContent.jsonXContent, invalidPoly)) { + parser.nextToken(); + expectThrows(XContentParseException.class, () -> GeoJson.fromXContent(parser, true, false, false)); + assertNull(parser.nextToken()); + } + + // test case 4: create an invalid polygon with null value points + invalidPoly = Strings.toString(XContentFactory.jsonBuilder().startObject().field("type", "polygon") + .startArray("coordinates") + .startArray() + .startArray().nullValue().nullValue().endArray() + .endArray() + .endArray() + .endObject()); + + try (XContentParser parser = createParser(JsonXContent.jsonXContent, invalidPoly)) { + parser.nextToken(); + expectThrows(XContentParseException.class, () -> GeoJson.fromXContent(parser, true, false, false)); + assertNull(parser.nextToken()); + } + + // test case 5: create an invalid polygon with 1 invalid LinearRing + invalidPoly = Strings.toString(XContentFactory.jsonBuilder().startObject().field("type", "polygon") + .startArray("coordinates") + .nullValue().nullValue() + .endArray() + .endObject()); + + try (XContentParser parser = createParser(JsonXContent.jsonXContent, invalidPoly)) { + parser.nextToken(); + expectThrows(XContentParseException.class, () -> GeoJson.fromXContent(parser, true, false, false)); + assertNull(parser.nextToken()); + } + + // test case 6: create an invalid polygon with 0 LinearRings + invalidPoly = Strings.toString(XContentFactory.jsonBuilder().startObject().field("type", "polygon") + .startArray("coordinates").endArray() + .endObject()); + + try (XContentParser parser = createParser(JsonXContent.jsonXContent, invalidPoly)) { + parser.nextToken(); + expectThrows(XContentParseException.class, () -> GeoJson.fromXContent(parser, true, false, false)); + assertNull(parser.nextToken()); + } + + // test case 7: create an invalid polygon with 0 LinearRings + invalidPoly = Strings.toString(XContentFactory.jsonBuilder().startObject().field("type", "polygon") + .startArray("coordinates") + .startArray().value(-74.011).value(40.753).endArray() + .endArray() + .endObject()); + + try (XContentParser parser = createParser(JsonXContent.jsonXContent, invalidPoly)) { + parser.nextToken(); + expectThrows(XContentParseException.class, () -> GeoJson.fromXContent(parser, true, false, false)); + assertNull(parser.nextToken()); + } + } + + public void testParsePolygonWithHole() throws IOException { + XContentBuilder polygonGeoJson = XContentFactory.jsonBuilder() + .startObject() + .field("type", "Polygon") + .startArray("coordinates") + .startArray() + .startArray().value(100.0).value(1.0).endArray() + .startArray().value(101.0).value(1.0).endArray() + .startArray().value(101.0).value(0.0).endArray() + .startArray().value(100.0).value(0.0).endArray() + .startArray().value(100.0).value(1.0).endArray() + .endArray() + .startArray() + .startArray().value(100.2).value(0.8).endArray() + .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() + .endArray() + .endArray() + .endObject(); + + LinearRing hole = + new LinearRing( + new double[] {0.8d, 0.2d, 0.2d, 0.8d, 0.8d}, new double[] {100.2d, 100.2d, 100.8d, 100.8d, 100.2d}); + Polygon p = + new Polygon(new LinearRing( + new double[] {1d, 1d, 0d, 0d, 1d}, new double[] {100d, 101d, 101d, 100d, 100d}), Collections.singletonList(hole)); + assertGeometryEquals(p, polygonGeoJson); + } + + @Override + public void testParseMultiPoint() throws IOException { + XContentBuilder 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(); + + assertGeometryEquals(new MultiPoint(Arrays.asList( + new Point(0, 100), + new Point(1, 101))), multiPointGeoJson); + } + + @Override + public void testParseMultiPolygon() throws IOException { + // two polygons; one without hole, one with hole + XContentBuilder multiPolygonGeoJson = XContentFactory.jsonBuilder() + .startObject() + .field("type", "MultiPolygon") + .startArray("coordinates") + .startArray()//first poly (without holes) + .startArray() + .startArray().value(102.0).value(2.0).endArray() + .startArray().value(103.0).value(2.0).endArray() + .startArray().value(103.0).value(3.0).endArray() + .startArray().value(102.0).value(3.0).endArray() + .startArray().value(102.0).value(2.0).endArray() + .endArray() + .endArray() + .startArray()//second poly (with hole) + .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()//hole + .startArray().value(100.2).value(0.8).endArray() + .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() + .endArray() + .endArray() + .endArray() + .endObject(); + + LinearRing hole = new LinearRing( + new double[] {0.8d, 0.2d, 0.2d, 0.8d, 0.8d}, new double[] {100.2d, 100.2d, 100.8d, 100.8d, 100.2d}); + + MultiPolygon polygons = new MultiPolygon(Arrays.asList( + new Polygon(new LinearRing( + new double[] {2d, 2d, 3d, 3d, 2d}, new double[] {102d, 103d, 103d, 102d, 102d})), + new Polygon(new LinearRing( + new double[] {0d, 0d, 1d, 1d, 0d}, new double[] {100d, 101d, 101d, 100d, 100d}), + Collections.singletonList(hole)))); + + assertGeometryEquals(polygons, multiPolygonGeoJson); + } + + @Override + public void testParseGeometryCollection() throws IOException { + XContentBuilder geometryCollectionGeoJson = XContentFactory.jsonBuilder() + .startObject() + .field("type", "GeometryCollection") + .startArray("geometries") + .startObject() + .field("type", "LineString") + .startArray("coordinates") + .startArray().value(100.0).value(0.0).endArray() + .startArray().value(101.0).value(1.0).endArray() + .endArray() + .endObject() + .startObject() + .field("type", "Point") + .startArray("coordinates").value(102.0).value(2.0).endArray() + .endObject() + .startObject() + .field("type", "Polygon") + .startArray("coordinates") + .startArray() + .startArray().value(-177.0).value(10.0).endArray() + .startArray().value(176.0).value(15.0).endArray() + .startArray().value(172.0).value(0.0).endArray() + .startArray().value(176.0).value(-15.0).endArray() + .startArray().value(-177.0).value(-10.0).endArray() + .startArray().value(-177.0).value(10.0).endArray() + .endArray() + .endArray() + .endObject() + .endArray() + .endObject(); + + GeometryCollection geometryExpected = new GeometryCollection<> (Arrays.asList( + new Line(new double[] {0d, 1d}, new double[] {100d, 101d}), + new Point(2d, 102d), + new Polygon(new LinearRing( + new double[] {10, 15, 0, -15, -10, 10}, + new double[] {-177, 176, 172, 176, -177, -177} + )) + )); + assertGeometryEquals(geometryExpected, geometryCollectionGeoJson); + } + + public void testThatParserExtractsCorrectTypeAndCoordinatesFromArbitraryJson() throws IOException { + XContentBuilder pointGeoJson = XContentFactory.jsonBuilder() + .startObject() + .startObject("crs") + .field("type", "name") + .startObject("properties") + .field("name", "urn:ogc:def:crs:OGC:1.3:CRS84") + .endObject() + .endObject() + .field("bbox", "foobar") + .field("type", "point") + .field("bubu", "foobar") + .startArray("coordinates").value(100.0).value(0.0).endArray() + .startObject("nested").startArray("coordinates").value(200.0).value(0.0).endArray().endObject() + .startObject("lala").field("type", "NotAPoint").endObject() + .endObject(); + + Point expectedPt = new Point(0, 100); + assertGeometryEquals(expectedPt, pointGeoJson, false); + } + + public void testParseOrientationOption() throws IOException { + // test 1: valid ccw (right handed system) poly not crossing dateline (with 'right' field) + XContentBuilder polygonGeoJson = XContentFactory.jsonBuilder() + .startObject() + .field("type", "Polygon") + .field("orientation", randomFrom("ccw", "right")) + .startArray("coordinates") + .startArray() + .startArray().value(176.0).value(15.0).endArray() + .startArray().value(-177.0).value(10.0).endArray() + .startArray().value(-177.0).value(-10.0).endArray() + .startArray().value(176.0).value(-15.0).endArray() + .startArray().value(172.0).value(0.0).endArray() + .startArray().value(176.0).value(15.0).endArray() + .endArray() + .startArray() + .startArray().value(-172.0).value(8.0).endArray() + .startArray().value(174.0).value(10.0).endArray() + .startArray().value(-172.0).value(-8.0).endArray() + .startArray().value(-172.0).value(8.0).endArray() + .endArray() + .endArray() + .endObject(); + + Polygon expected = new Polygon( + new LinearRing(new double[]{15.0, 10.0, -10.0, -15.0, 0.0, 15.0}, new double[]{176.0, -177.0, -177.0, 176.0, 172.0, 176.0}), + Collections.singletonList( + new LinearRing(new double[]{8.0, 10.0, -8.0, 8.0}, new double[]{-172.0, 174.0, -172.0, -172.0}) + )); + assertGeometryEquals(expected, polygonGeoJson); + + // test 2: valid cw poly + polygonGeoJson = XContentFactory.jsonBuilder() + .startObject() + .field("type", "Polygon") + .field("orientation", randomFrom("cw", "left")) + .startArray("coordinates") + .startArray() + .startArray().value(176.0).value(15.0).endArray() + .startArray().value(-177.0).value(10.0).endArray() + .startArray().value(-177.0).value(-10.0).endArray() + .startArray().value(176.0).value(-15.0).endArray() + .startArray().value(172.0).value(0.0).endArray() + .startArray().value(176.0).value(15.0).endArray() + .endArray() + .startArray() + .startArray().value(-172.0).value(8.0).endArray() + .startArray().value(174.0).value(10.0).endArray() + .startArray().value(-172.0).value(-8.0).endArray() + .startArray().value(-172.0).value(8.0).endArray() + .endArray() + .endArray() + .endObject(); + + expected = new Polygon( + new LinearRing(new double[]{15.0, 0.0, -15.0, -10.0, 10.0, 15.0}, new double[]{176.0, 172.0, 176.0, -177.0, -177.0, 176.0}), + Collections.singletonList( + new LinearRing(new double[]{8.0, -8.0, 10.0, 8.0}, new double[]{-172.0, -172.0, 174.0, -172.0}) + )); + assertGeometryEquals(expected, polygonGeoJson); + } + + public void testParseInvalidShapes() throws IOException { + // single dimensions point + XContentBuilder tooLittlePointGeoJson = XContentFactory.jsonBuilder() + .startObject() + .field("type", "Point") + .startArray("coordinates").value(10.0).endArray() + .endObject(); + + try (XContentParser parser = createParser(tooLittlePointGeoJson)) { + parser.nextToken(); + expectThrows(XContentParseException.class, () -> GeoJson.fromXContent(parser, true, false, false)); + assertNull(parser.nextToken()); + } + + // zero dimensions point + XContentBuilder emptyPointGeoJson = XContentFactory.jsonBuilder() + .startObject() + .field("type", "Point") + .startObject("coordinates").field("foo", "bar").endObject() + .endObject(); + + try (XContentParser parser = createParser(emptyPointGeoJson)) { + parser.nextToken(); + expectThrows(XContentParseException.class, () -> GeoJson.fromXContent(parser, true, false, false)); + assertNull(parser.nextToken()); + } + } + + public void testParseInvalidGeometryCollectionShapes() throws IOException { + // single dimensions point + XContentBuilder invalidPoints = XContentFactory.jsonBuilder() + .startObject() + .startObject("foo") + .field("type", "geometrycollection") + .startArray("geometries") + .startObject() + .field("type", "polygon") + .startArray("coordinates") + .startArray().value("46.6022226498514").value("24.7237442867977").endArray() + .startArray().value("46.6031857243798").value("24.722968774929").endArray() + .endArray() // coordinates + .endObject() + .endArray() // geometries + .endObject() + .endObject(); + try (XContentParser parser = createParser(invalidPoints)) { + parser.nextToken(); // foo + parser.nextToken(); // start object + parser.nextToken(); // start object + expectThrows(XContentParseException.class, () -> GeoJson.fromXContent(parser, true, false, false)); + assertEquals(XContentParser.Token.END_OBJECT, parser.nextToken()); // end of the document + assertNull(parser.nextToken()); // no more elements afterwards + } + } +} diff --git a/server/src/test/java/org/elasticsearch/common/geo/GeoJsonSerializationTests.java b/server/src/test/java/org/elasticsearch/common/geo/GeoJsonSerializationTests.java new file mode 100644 index 0000000000000..ab6e3242654f5 --- /dev/null +++ b/server/src/test/java/org/elasticsearch/common/geo/GeoJsonSerializationTests.java @@ -0,0 +1,269 @@ +/* + * Licensed to Elasticsearch under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch 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.geo; + +import org.elasticsearch.common.xcontent.ToXContent; +import org.elasticsearch.common.xcontent.ToXContentObject; +import org.elasticsearch.common.xcontent.XContentBuilder; +import org.elasticsearch.common.xcontent.XContentParser; +import org.elasticsearch.geo.geometry.Circle; +import org.elasticsearch.geo.geometry.Geometry; +import org.elasticsearch.geo.geometry.GeometryCollection; +import org.elasticsearch.geo.geometry.Line; +import org.elasticsearch.geo.geometry.LinearRing; +import org.elasticsearch.geo.geometry.MultiLine; +import org.elasticsearch.geo.geometry.MultiPoint; +import org.elasticsearch.geo.geometry.MultiPolygon; +import org.elasticsearch.geo.geometry.Point; +import org.elasticsearch.geo.geometry.Polygon; +import org.elasticsearch.geo.geometry.Rectangle; +import org.elasticsearch.test.AbstractXContentTestCase; +import org.elasticsearch.test.ESTestCase; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.function.Function; +import java.util.function.Supplier; + +public class GeoJsonSerializationTests extends ESTestCase { + + private static class GeometryWrapper implements ToXContentObject { + + private Geometry geometry; + + GeometryWrapper(Geometry geometry) { + this.geometry = geometry; + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + return GeoJson.toXContent(geometry, builder, params); + } + + public static GeometryWrapper fromXContent(XContentParser parser) throws IOException { + parser.nextToken(); + return new GeometryWrapper(GeoJson.fromXContent(parser, true, false, true)); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + GeometryWrapper that = (GeometryWrapper) o; + return Objects.equals(geometry, that.geometry); + } + + @Override + public int hashCode() { + return Objects.hash(geometry); + } + } + + + private void xContentTest(Supplier instanceSupplier) throws IOException { + AbstractXContentTestCase.xContentTester( + this::createParser, + () -> new GeometryWrapper(instanceSupplier.get()), + (geometryWrapper, xContentBuilder) -> { + geometryWrapper.toXContent(xContentBuilder, ToXContent.EMPTY_PARAMS); + }, + GeometryWrapper::fromXContent) + .supportsUnknownFields(true) + .test(); + } + + + public void testPoint() throws IOException { + xContentTest(() -> randomPoint(randomBoolean())); + } + + public void testMultiPoint() throws IOException { + xContentTest(() -> randomMultiPoint(randomBoolean())); + } + + public void testLineString() throws IOException { + xContentTest(() -> randomLine(randomBoolean())); + } + + public void testMultiLineString() throws IOException { + xContentTest(() -> randomMultiLine(randomBoolean())); + } + + public void testPolygon() throws IOException { + xContentTest(() -> randomPolygon(randomBoolean())); + } + + public void testMultiPolygon() throws IOException { + xContentTest(() -> randomMultiPolygon(randomBoolean())); + } + + public void testEnvelope() throws IOException { + xContentTest(GeoJsonSerializationTests::randomRectangle); + } + + public void testGeometryCollection() throws IOException { + xContentTest(() -> randomGeometryCollection(randomBoolean())); + } + + public void testCircle() throws IOException { + xContentTest(() -> randomCircle(randomBoolean())); + } + + public static double randomLat() { + return randomDoubleBetween(-90, 90, true); + } + + public static double randomLon() { + return randomDoubleBetween(-180, 180, true); + } + + public static Circle randomCircle(boolean hasAlt) { + if (hasAlt) { + return new Circle(randomDoubleBetween(-90, 90, true), randomDoubleBetween(-180, 180, true), randomDouble(), + randomDoubleBetween(0, 100, false)); + } else { + return new Circle(randomDoubleBetween(-90, 90, true), randomDoubleBetween(-180, 180, true), randomDoubleBetween(0, 100, false)); + } + } + + public static Line randomLine(boolean hasAlts) { + int size = randomIntBetween(2, 10); + double[] lats = new double[size]; + double[] lons = new double[size]; + double[] alts = hasAlts ? new double[size] : null; + for (int i = 0; i < size; i++) { + lats[i] = randomLat(); + lons[i] = randomLon(); + if (hasAlts) { + alts[i] = randomDouble(); + } + } + if (hasAlts) { + return new Line(lats, lons, alts); + } + return new Line(lats, lons); + } + + public static Point randomPoint(boolean hasAlt) { + if (hasAlt) { + return new Point(randomLat(), randomLon(), randomDouble()); + } else { + return new Point(randomLat(), randomLon()); + } + } + + public static MultiPoint randomMultiPoint(boolean hasAlt) { + int size = randomIntBetween(3, 10); + List points = new ArrayList<>(); + for (int i = 0; i < size; i++) { + points.add(randomPoint(hasAlt)); + } + return new MultiPoint(points); + } + + public static MultiLine randomMultiLine(boolean hasAlt) { + int size = randomIntBetween(3, 10); + List lines = new ArrayList<>(); + for (int i = 0; i < size; i++) { + lines.add(randomLine(hasAlt)); + } + return new MultiLine(lines); + } + + public static MultiPolygon randomMultiPolygon(boolean hasAlt) { + int size = randomIntBetween(3, 10); + List polygons = new ArrayList<>(); + for (int i = 0; i < size; i++) { + polygons.add(randomPolygon(hasAlt)); + } + return new MultiPolygon(polygons); + } + + public static LinearRing randomLinearRing(boolean hasAlt) { + int size = randomIntBetween(3, 10); + double[] lats = new double[size + 1]; + double[] lons = new double[size + 1]; + double[] alts; + if (hasAlt) { + alts = new double[size + 1]; + } else { + alts = null; + } + for (int i = 0; i < size; i++) { + lats[i] = randomLat(); + lons[i] = randomLon(); + if (hasAlt) { + alts[i] = randomDouble(); + } + } + lats[size] = lats[0]; + lons[size] = lons[0]; + if (hasAlt) { + alts[size] = alts[0]; + return new LinearRing(lats, lons, alts); + } else { + return new LinearRing(lats, lons); + } + } + + public static Polygon randomPolygon(boolean hasAlt) { + int size = randomIntBetween(0, 10); + List holes = new ArrayList<>(); + for (int i = 0; i < size; i++) { + holes.add(randomLinearRing(hasAlt)); + } + if (holes.size() > 0) { + return new Polygon(randomLinearRing(hasAlt), holes); + } else { + return new Polygon(randomLinearRing(hasAlt)); + } + } + + public static Rectangle randomRectangle() { + double lat1 = randomLat(); + double lat2 = randomLat(); + double minLon = randomLon(); + double maxLon = randomLon(); + return new Rectangle(Math.min(lat1, lat2), Math.max(lat1, lat2), minLon, maxLon); + } + + public static GeometryCollection randomGeometryCollection(boolean hasAlt) { + return randomGeometryCollection(0, hasAlt); + } + + private static GeometryCollection randomGeometryCollection(int level, boolean hasAlt) { + int size = randomIntBetween(1, 10); + List shapes = new ArrayList<>(); + for (int i = 0; i < size; i++) { + @SuppressWarnings("unchecked") Function geometry = randomFrom( + GeoJsonSerializationTests::randomCircle, + GeoJsonSerializationTests::randomLine, + GeoJsonSerializationTests::randomPoint, + GeoJsonSerializationTests::randomPolygon, + hasAlt ? GeoJsonSerializationTests::randomPoint : (b) -> randomRectangle(), + level < 3 ? (b) -> randomGeometryCollection(level + 1, b) : GeoJsonSerializationTests::randomPoint // don't build too deep + ); + shapes.add(geometry.apply(hasAlt)); + } + return new GeometryCollection<>(shapes); + } +} diff --git a/server/src/test/java/org/elasticsearch/common/geo/GeometryParserTests.java b/server/src/test/java/org/elasticsearch/common/geo/GeometryParserTests.java new file mode 100644 index 0000000000000..7b5bad17fe2bb --- /dev/null +++ b/server/src/test/java/org/elasticsearch/common/geo/GeometryParserTests.java @@ -0,0 +1,156 @@ +/* + * Licensed to Elasticsearch under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch 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.geo; + +import org.elasticsearch.ElasticsearchParseException; +import org.elasticsearch.Version; +import org.elasticsearch.cluster.metadata.IndexMetaData; +import org.elasticsearch.common.UUIDs; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.common.xcontent.XContentBuilder; +import org.elasticsearch.common.xcontent.XContentFactory; +import org.elasticsearch.common.xcontent.XContentParseException; +import org.elasticsearch.common.xcontent.XContentParser; +import org.elasticsearch.geo.geometry.LinearRing; +import org.elasticsearch.geo.geometry.Point; +import org.elasticsearch.geo.geometry.Polygon; +import org.elasticsearch.index.mapper.ContentPath; +import org.elasticsearch.index.mapper.GeoShapeFieldMapper; +import org.elasticsearch.index.mapper.Mapper; +import org.elasticsearch.test.ESTestCase; + +/** + * Tests for {@link GeometryParser} + */ +public class GeometryParserTests extends ESTestCase { + + public void testGeoJsonParsing() throws Exception { + + XContentBuilder pointGeoJson = XContentFactory.jsonBuilder() + .startObject() + .field("type", "Point") + .startArray("coordinates").value(100.0).value(0.0).endArray() + .endObject(); + + try (XContentParser parser = createParser(pointGeoJson)) { + parser.nextToken(); + assertEquals(new Point(0, 100), GeometryParser.parse(parser, createMapper(randomBoolean(), randomBoolean()))); + } + + XContentBuilder pointGeoJsonWithZ = XContentFactory.jsonBuilder() + .startObject() + .field("type", "Point") + .startArray("coordinates").value(100.0).value(0.0).value(10.0).endArray() + .endObject(); + + try (XContentParser parser = createParser(pointGeoJsonWithZ)) { + parser.nextToken(); + assertEquals(new Point(0, 100, 10.0), GeometryParser.parse(parser, createMapper(true, randomBoolean()))); + } + + + try (XContentParser parser = createParser(pointGeoJsonWithZ)) { + parser.nextToken(); + expectThrows(XContentParseException.class, () -> GeometryParser.parse(parser, createMapper(false, randomBoolean()))); + } + + XContentBuilder polygonGeoJson = XContentFactory.jsonBuilder() + .startObject() + .field("type", "Polygon") + .startArray("coordinates") + .startArray() + .startArray().value(100.0).value(1.0).endArray() + .startArray().value(101.0).value(1.0).endArray() + .startArray().value(101.0).value(0.0).endArray() + .startArray().value(100.0).value(0.0).endArray() + .endArray() + .endArray() + .endObject(); + + Polygon p = new Polygon(new LinearRing(new double[] {1d, 1d, 0d, 0d, 1d}, new double[] {100d, 101d, 101d, 100d, 100d})); + try (XContentParser parser = createParser(polygonGeoJson)) { + parser.nextToken(); + // Coerce should automatically close the polygon + assertEquals(p, GeometryParser.parse(parser, createMapper(randomBoolean(), true))); + } + + try (XContentParser parser = createParser(polygonGeoJson)) { + parser.nextToken(); + // No coerce - the polygon parsing should fail + expectThrows(XContentParseException.class, () -> GeometryParser.parse(parser, createMapper(randomBoolean(), false))); + } + } + + public void testWKTParsing() throws Exception { + XContentBuilder pointGeoJson = XContentFactory.jsonBuilder() + .startObject() + .field("foo", "Point (100 0)") + .endObject(); + + try (XContentParser parser = createParser(pointGeoJson)) { + parser.nextToken(); // Start object + parser.nextToken(); // Field Name + parser.nextToken(); // Field Value + assertEquals(new Point(0, 100), GeometryParser.parse(parser, createMapper(randomBoolean(), randomBoolean()))); + } + } + + public void testNullParsing() throws Exception { + XContentBuilder pointGeoJson = XContentFactory.jsonBuilder() + .startObject() + .nullField("foo") + .endObject(); + + try (XContentParser parser = createParser(pointGeoJson)) { + parser.nextToken(); // Start object + parser.nextToken(); // Field Name + parser.nextToken(); // Field Value + assertNull(GeometryParser.parse(parser, createMapper(randomBoolean(), randomBoolean()))); + } + } + + public void testUnsupportedValueParsing() throws Exception { + XContentBuilder pointGeoJson = XContentFactory.jsonBuilder() + .startObject() + .field("foo", 42) + .endObject(); + + try (XContentParser parser = createParser(pointGeoJson)) { + parser.nextToken(); // Start object + parser.nextToken(); // Field Name + parser.nextToken(); // Field Value + ElasticsearchParseException ex = expectThrows(ElasticsearchParseException.class, + () -> GeometryParser.parse(parser, createMapper(randomBoolean(), randomBoolean()))); + assertEquals("shape must be an object consisting of type and coordinates", ex.getMessage()); + } + } + + public GeoShapeFieldMapper createMapper(boolean ignoreZValue, boolean coerce) { + Settings indexSettings = Settings.builder() + .put(IndexMetaData.SETTING_VERSION_CREATED, Version.CURRENT) + .put(IndexMetaData.SETTING_NUMBER_OF_REPLICAS, 0) + .put(IndexMetaData.SETTING_NUMBER_OF_SHARDS, 1) + .put(IndexMetaData.SETTING_INDEX_UUID, UUIDs.randomBase64UUID()).build(); + + Mapper.BuilderContext mockBuilderContext = new Mapper.BuilderContext(indexSettings, new ContentPath()); + return (GeoShapeFieldMapper) (new GeoShapeFieldMapper.Builder("test").ignoreZValue(ignoreZValue).coerce(coerce) + .build(mockBuilderContext)); + } +} From 09efc211ab42d0fc2975b54c39b2b13d18677b13 Mon Sep 17 00:00:00 2001 From: Igor Motov Date: Fri, 26 Apr 2019 15:29:09 -0400 Subject: [PATCH 2/4] Remove mapper dependency from GeometryParser --- .../common/geo/GeometryParser.java | 18 ++-------- .../common/geo/GeometryParserTests.java | 35 +++++-------------- 2 files changed, 10 insertions(+), 43 deletions(-) diff --git a/server/src/main/java/org/elasticsearch/common/geo/GeometryParser.java b/server/src/main/java/org/elasticsearch/common/geo/GeometryParser.java index 5bb0678542a5d..27790a96ec196 100644 --- a/server/src/main/java/org/elasticsearch/common/geo/GeometryParser.java +++ b/server/src/main/java/org/elasticsearch/common/geo/GeometryParser.java @@ -20,11 +20,9 @@ package org.elasticsearch.common.geo; import org.elasticsearch.ElasticsearchParseException; -import org.elasticsearch.common.geo.builders.ShapeBuilder; import org.elasticsearch.common.xcontent.XContentParser; import org.elasticsearch.geo.geometry.Geometry; import org.elasticsearch.geo.utils.WellKnownText; -import org.elasticsearch.index.mapper.BaseGeoShapeFieldMapper; import java.io.IOException; import java.text.ParseException; @@ -41,23 +39,11 @@ private GeometryParser() { /** * Parses supplied XContent into Geometry */ - static Geometry parse(XContentParser parser, BaseGeoShapeFieldMapper shapeMapper) throws IOException, ParseException { + static Geometry parse(XContentParser parser, boolean orientation, boolean coerce, boolean ignoreZValue) throws IOException, + ParseException { if (parser.currentToken() == XContentParser.Token.VALUE_NULL) { return null; } else if (parser.currentToken() == XContentParser.Token.START_OBJECT) { - boolean orientation; - boolean coerce; - boolean ignoreZValue; - - if (shapeMapper == null) { - orientation = true; - coerce = true; - ignoreZValue = true; - } else { - orientation = shapeMapper.orientation() == ShapeBuilder.Orientation.RIGHT; - coerce = shapeMapper.coerce().value(); - ignoreZValue = shapeMapper.ignoreZValue().value(); - } return GeoJson.fromXContent(parser, orientation, coerce, ignoreZValue); } else if (parser.currentToken() == XContentParser.Token.VALUE_STRING) { // TODO: Add support for ignoreZValue and coerce to WKT diff --git a/server/src/test/java/org/elasticsearch/common/geo/GeometryParserTests.java b/server/src/test/java/org/elasticsearch/common/geo/GeometryParserTests.java index 7b5bad17fe2bb..24ba7780cefd6 100644 --- a/server/src/test/java/org/elasticsearch/common/geo/GeometryParserTests.java +++ b/server/src/test/java/org/elasticsearch/common/geo/GeometryParserTests.java @@ -20,10 +20,6 @@ package org.elasticsearch.common.geo; import org.elasticsearch.ElasticsearchParseException; -import org.elasticsearch.Version; -import org.elasticsearch.cluster.metadata.IndexMetaData; -import org.elasticsearch.common.UUIDs; -import org.elasticsearch.common.settings.Settings; import org.elasticsearch.common.xcontent.XContentBuilder; import org.elasticsearch.common.xcontent.XContentFactory; import org.elasticsearch.common.xcontent.XContentParseException; @@ -31,9 +27,6 @@ import org.elasticsearch.geo.geometry.LinearRing; import org.elasticsearch.geo.geometry.Point; import org.elasticsearch.geo.geometry.Polygon; -import org.elasticsearch.index.mapper.ContentPath; -import org.elasticsearch.index.mapper.GeoShapeFieldMapper; -import org.elasticsearch.index.mapper.Mapper; import org.elasticsearch.test.ESTestCase; /** @@ -51,7 +44,7 @@ public void testGeoJsonParsing() throws Exception { try (XContentParser parser = createParser(pointGeoJson)) { parser.nextToken(); - assertEquals(new Point(0, 100), GeometryParser.parse(parser, createMapper(randomBoolean(), randomBoolean()))); + assertEquals(new Point(0, 100), GeometryParser.parse(parser, true, randomBoolean(), randomBoolean())); } XContentBuilder pointGeoJsonWithZ = XContentFactory.jsonBuilder() @@ -62,13 +55,13 @@ public void testGeoJsonParsing() throws Exception { try (XContentParser parser = createParser(pointGeoJsonWithZ)) { parser.nextToken(); - assertEquals(new Point(0, 100, 10.0), GeometryParser.parse(parser, createMapper(true, randomBoolean()))); + assertEquals(new Point(0, 100, 10.0), GeometryParser.parse(parser, true, randomBoolean(), true)); } try (XContentParser parser = createParser(pointGeoJsonWithZ)) { parser.nextToken(); - expectThrows(XContentParseException.class, () -> GeometryParser.parse(parser, createMapper(false, randomBoolean()))); + expectThrows(XContentParseException.class, () -> GeometryParser.parse(parser, true, randomBoolean(), false)); } XContentBuilder polygonGeoJson = XContentFactory.jsonBuilder() @@ -88,13 +81,13 @@ public void testGeoJsonParsing() throws Exception { try (XContentParser parser = createParser(polygonGeoJson)) { parser.nextToken(); // Coerce should automatically close the polygon - assertEquals(p, GeometryParser.parse(parser, createMapper(randomBoolean(), true))); + assertEquals(p, GeometryParser.parse(parser, true, true, randomBoolean())); } try (XContentParser parser = createParser(polygonGeoJson)) { parser.nextToken(); // No coerce - the polygon parsing should fail - expectThrows(XContentParseException.class, () -> GeometryParser.parse(parser, createMapper(randomBoolean(), false))); + expectThrows(XContentParseException.class, () -> GeometryParser.parse(parser, true, false, randomBoolean())); } } @@ -108,7 +101,7 @@ public void testWKTParsing() throws Exception { parser.nextToken(); // Start object parser.nextToken(); // Field Name parser.nextToken(); // Field Value - assertEquals(new Point(0, 100), GeometryParser.parse(parser, createMapper(randomBoolean(), randomBoolean()))); + assertEquals(new Point(0, 100), GeometryParser.parse(parser, true, randomBoolean(), randomBoolean())); } } @@ -122,7 +115,7 @@ public void testNullParsing() throws Exception { parser.nextToken(); // Start object parser.nextToken(); // Field Name parser.nextToken(); // Field Value - assertNull(GeometryParser.parse(parser, createMapper(randomBoolean(), randomBoolean()))); + assertNull(GeometryParser.parse(parser, true, randomBoolean(), randomBoolean())); } } @@ -137,20 +130,8 @@ public void testUnsupportedValueParsing() throws Exception { parser.nextToken(); // Field Name parser.nextToken(); // Field Value ElasticsearchParseException ex = expectThrows(ElasticsearchParseException.class, - () -> GeometryParser.parse(parser, createMapper(randomBoolean(), randomBoolean()))); + () -> GeometryParser.parse(parser, true, randomBoolean(), randomBoolean())); assertEquals("shape must be an object consisting of type and coordinates", ex.getMessage()); } } - - public GeoShapeFieldMapper createMapper(boolean ignoreZValue, boolean coerce) { - Settings indexSettings = Settings.builder() - .put(IndexMetaData.SETTING_VERSION_CREATED, Version.CURRENT) - .put(IndexMetaData.SETTING_NUMBER_OF_REPLICAS, 0) - .put(IndexMetaData.SETTING_NUMBER_OF_SHARDS, 1) - .put(IndexMetaData.SETTING_INDEX_UUID, UUIDs.randomBase64UUID()).build(); - - Mapper.BuilderContext mockBuilderContext = new Mapper.BuilderContext(indexSettings, new ContentPath()); - return (GeoShapeFieldMapper) (new GeoShapeFieldMapper.Builder("test").ignoreZValue(ignoreZValue).coerce(coerce) - .build(mockBuilderContext)); - } } From 0a3f837c92986d8a504b66a50d349ba9ff17adb9 Mon Sep 17 00:00:00 2001 From: Igor Motov Date: Fri, 26 Apr 2019 15:59:43 -0400 Subject: [PATCH 3/4] Addressing misc review comments --- .../main/java/org/elasticsearch/geo/geometry/ShapeType.java | 4 ++-- .../src/main/java/org/elasticsearch/common/geo/GeoJson.java | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/libs/geo/src/main/java/org/elasticsearch/geo/geometry/ShapeType.java b/libs/geo/src/main/java/org/elasticsearch/geo/geometry/ShapeType.java index 3f6933c688462..48a262a8316e3 100644 --- a/libs/geo/src/main/java/org/elasticsearch/geo/geometry/ShapeType.java +++ b/libs/geo/src/main/java/org/elasticsearch/geo/geometry/ShapeType.java @@ -36,7 +36,7 @@ public enum ShapeType { ENVELOPE, // not part of the actual WKB spec CIRCLE; // not part of the actual WKB spec - public static ShapeType forName(String geoshapename) { - return ShapeType.valueOf(geoshapename.toUpperCase(Locale.ROOT)); + public static ShapeType forName(String shapeName) { + return ShapeType.valueOf(shapeName.toUpperCase(Locale.ROOT)); } } diff --git a/server/src/main/java/org/elasticsearch/common/geo/GeoJson.java b/server/src/main/java/org/elasticsearch/common/geo/GeoJson.java index c150231ad9097..3489eca8b58e4 100644 --- a/server/src/main/java/org/elasticsearch/common/geo/GeoJson.java +++ b/server/src/main/java/org/elasticsearch/common/geo/GeoJson.java @@ -103,7 +103,7 @@ public XContentBuilder visit(Line line) throws IOException { @Override public XContentBuilder visit(LinearRing ring) { - throw new UnsupportedOperationException("line ring cannot be serialized using GeoJson"); + throw new UnsupportedOperationException("linearRing cannot be serialized using GeoJson"); } @Override From 007f2026b9115c388f06275a4015d8c95199230d Mon Sep 17 00:00:00 2001 From: Igor Motov Date: Sun, 28 Apr 2019 16:58:52 -0400 Subject: [PATCH 4/4] Make the parse method public since it is needed by geosql --- .../main/java/org/elasticsearch/common/geo/GeometryParser.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/main/java/org/elasticsearch/common/geo/GeometryParser.java b/server/src/main/java/org/elasticsearch/common/geo/GeometryParser.java index 27790a96ec196..8e1db18ccdd97 100644 --- a/server/src/main/java/org/elasticsearch/common/geo/GeometryParser.java +++ b/server/src/main/java/org/elasticsearch/common/geo/GeometryParser.java @@ -39,7 +39,7 @@ private GeometryParser() { /** * Parses supplied XContent into Geometry */ - static Geometry parse(XContentParser parser, boolean orientation, boolean coerce, boolean ignoreZValue) throws IOException, + public static Geometry parse(XContentParser parser, boolean orientation, boolean coerce, boolean ignoreZValue) throws IOException, ParseException { if (parser.currentToken() == XContentParser.Token.VALUE_NULL) { return null;