Skip to content

Commit bc9a470

Browse files
committed
[GEO] Update ShapeBuilder and GeoPolygonQueryParser to accept unclosed GeoJSON
While the GeoJSON spec does say a polygon is represented as an array of LinearRings (where a LinearRing is defined as a 'closed' array of points), the coerce parameter provides users with flexibility to have ES automatically close polygons. This addresses situations like those integrated with twitter (where GeoJSON polygons are not closed) such that our users do not have to write extra code to close the polygon. This code change adds the optional coerce parameter to the GeoShapeFieldMapper. closes #11131
1 parent c3f44e5 commit bc9a470

File tree

6 files changed

+146
-26
lines changed

6 files changed

+146
-26
lines changed

core/src/main/java/org/elasticsearch/common/geo/builders/ShapeBuilder.java

Lines changed: 33 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
import com.vividsolutions.jts.geom.GeometryFactory;
2929
import org.apache.commons.lang3.tuple.Pair;
3030
import org.elasticsearch.ElasticsearchParseException;
31+
import org.elasticsearch.common.Explicit;
3132
import org.elasticsearch.common.logging.ESLogger;
3233
import org.elasticsearch.common.logging.ESLoggerFactory;
3334
import org.elasticsearch.common.unit.DistanceUnit.Distance;
@@ -728,7 +729,9 @@ public static ShapeBuilder parse(XContentParser parser, GeoShapeFieldMapper shap
728729
Distance radius = null;
729730
CoordinateNode node = null;
730731
GeometryCollectionBuilder geometryCollections = null;
732+
731733
Orientation requestedOrientation = (shapeMapper == null) ? Orientation.RIGHT : shapeMapper.fieldType().orientation();
734+
boolean coerce = (shapeMapper == null) ? GeoShapeFieldMapper.Defaults.COERCE.value() : shapeMapper.coerce().value();
732735

733736
XContentParser.Token token;
734737
while ((token = parser.nextToken()) != XContentParser.Token.END_OBJECT) {
@@ -743,7 +746,7 @@ public static ShapeBuilder parse(XContentParser parser, GeoShapeFieldMapper shap
743746
node = parseCoordinates(parser);
744747
} else if (FIELD_GEOMETRIES.equals(fieldName)) {
745748
parser.nextToken();
746-
geometryCollections = parseGeometries(parser, requestedOrientation);
749+
geometryCollections = parseGeometries(parser, shapeMapper);
747750
} else if (CircleBuilder.FIELD_RADIUS.equals(fieldName)) {
748751
parser.nextToken();
749752
radius = Distance.parseDistance(parser.text());
@@ -772,8 +775,8 @@ public static ShapeBuilder parse(XContentParser parser, GeoShapeFieldMapper shap
772775
case MULTIPOINT: return parseMultiPoint(node);
773776
case LINESTRING: return parseLineString(node);
774777
case MULTILINESTRING: return parseMultiLine(node);
775-
case POLYGON: return parsePolygon(node, requestedOrientation);
776-
case MULTIPOLYGON: return parseMultiPolygon(node, requestedOrientation);
778+
case POLYGON: return parsePolygon(node, requestedOrientation, coerce);
779+
case MULTIPOLYGON: return parseMultiPolygon(node, requestedOrientation, coerce);
777780
case CIRCLE: return parseCircle(node, radius);
778781
case ENVELOPE: return parseEnvelope(node, requestedOrientation);
779782
case GEOMETRYCOLLECTION: return geometryCollections;
@@ -801,7 +804,7 @@ protected static CircleBuilder parseCircle(CoordinateNode coordinates, Distance
801804
return newCircleBuilder().center(coordinates.coordinate).radius(radius);
802805
}
803806

804-
protected static EnvelopeBuilder parseEnvelope(CoordinateNode coordinates, Orientation orientation) {
807+
protected static EnvelopeBuilder parseEnvelope(CoordinateNode coordinates, final Orientation orientation) {
805808
// validate the coordinate array for envelope type
806809
if (coordinates.children.size() != 2) {
807810
throw new ElasticsearchParseException("invalid number of points [{}] provided for " +
@@ -868,7 +871,7 @@ protected static MultiLineStringBuilder parseMultiLine(CoordinateNode coordinate
868871
return multiline;
869872
}
870873

871-
protected static LineStringBuilder parseLinearRing(CoordinateNode coordinates) {
874+
protected static LineStringBuilder parseLinearRing(CoordinateNode coordinates, boolean coerce) {
872875
/**
873876
* Per GeoJSON spec (http://geojson.org/geojson-spec.html#linestring)
874877
* A LinearRing is closed LineString with 4 or more positions. The first and last positions
@@ -880,32 +883,43 @@ protected static LineStringBuilder parseLinearRing(CoordinateNode coordinates) {
880883
error += (coordinates.coordinate == null) ?
881884
" No coordinate array provided" : " Found a single coordinate when expecting a coordinate array";
882885
throw new ElasticsearchParseException(error);
883-
} else if (coordinates.children.size() < 4) {
884-
throw new ElasticsearchParseException("invalid number of points in LinearRing (found [{}] - must be >= 4)", coordinates.children.size());
885-
} else if (!coordinates.children.get(0).coordinate.equals(
886-
coordinates.children.get(coordinates.children.size() - 1).coordinate)) {
887-
throw new ElasticsearchParseException("invalid LinearRing found (coordinates are not closed)");
886+
}
887+
888+
int numValidPts;
889+
if (coordinates.children.size() < (numValidPts = (coerce) ? 3 : 4)) {
890+
throw new ElasticsearchParseException("invalid number of points in LinearRing (found [{}] - must be >= " + numValidPts + ")(",
891+
coordinates.children.size());
892+
}
893+
894+
if (!coordinates.children.get(0).coordinate.equals(
895+
coordinates.children.get(coordinates.children.size() - 1).coordinate)) {
896+
if (coerce) {
897+
coordinates.children.add(coordinates.children.get(0));
898+
} else {
899+
throw new ElasticsearchParseException("invalid LinearRing found (coordinates are not closed)");
900+
}
888901
}
889902
return parseLineString(coordinates);
890903
}
891904

892-
protected static PolygonBuilder parsePolygon(CoordinateNode coordinates, Orientation orientation) {
905+
protected static PolygonBuilder parsePolygon(CoordinateNode coordinates, final Orientation orientation, final boolean coerce) {
893906
if (coordinates.children == null || coordinates.children.isEmpty()) {
894907
throw new ElasticsearchParseException("invalid LinearRing provided for type polygon. Linear ring must be an array of coordinates");
895908
}
896909

897-
LineStringBuilder shell = parseLinearRing(coordinates.children.get(0));
910+
LineStringBuilder shell = parseLinearRing(coordinates.children.get(0), coerce);
898911
PolygonBuilder polygon = new PolygonBuilder(shell.points, orientation);
899912
for (int i = 1; i < coordinates.children.size(); i++) {
900-
polygon.hole(parseLinearRing(coordinates.children.get(i)));
913+
polygon.hole(parseLinearRing(coordinates.children.get(i), coerce));
901914
}
902915
return polygon;
903916
}
904917

905-
protected static MultiPolygonBuilder parseMultiPolygon(CoordinateNode coordinates, Orientation orientation) {
918+
protected static MultiPolygonBuilder parseMultiPolygon(CoordinateNode coordinates, final Orientation orientation,
919+
final boolean coerce) {
906920
MultiPolygonBuilder polygons = newMultiPolygon(orientation);
907921
for (CoordinateNode node : coordinates.children) {
908-
polygons.polygon(parsePolygon(node, orientation));
922+
polygons.polygon(parsePolygon(node, orientation, coerce));
909923
}
910924
return polygons;
911925
}
@@ -917,13 +931,15 @@ protected static MultiPolygonBuilder parseMultiPolygon(CoordinateNode coordinate
917931
* @return Geometry[] geometries of the GeometryCollection
918932
* @throws IOException Thrown if an error occurs while reading from the XContentParser
919933
*/
920-
protected static GeometryCollectionBuilder parseGeometries(XContentParser parser, Orientation orientation) throws IOException {
934+
protected static GeometryCollectionBuilder parseGeometries(XContentParser parser, GeoShapeFieldMapper mapper) throws
935+
IOException {
921936
if (parser.currentToken() != XContentParser.Token.START_ARRAY) {
922937
throw new ElasticsearchParseException("geometries must be an array of geojson objects");
923938
}
924939

925940
XContentParser.Token token = parser.nextToken();
926-
GeometryCollectionBuilder geometryCollection = newGeometryCollection(orientation);
941+
GeometryCollectionBuilder geometryCollection = newGeometryCollection( (mapper == null) ? Orientation.RIGHT : mapper
942+
.fieldType().orientation());
927943
while (token != XContentParser.Token.END_ARRAY) {
928944
ShapeBuilder shapeBuilder = GeoShapeType.parse(parser);
929945
geometryCollection.shape(shapeBuilder);

core/src/main/java/org/elasticsearch/index/mapper/geo/GeoPointFieldMapper.java

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -636,9 +636,7 @@ public Mapper parse(ParseContext context) throws IOException {
636636
double lon = context.parser().doubleValue();
637637
token = context.parser().nextToken();
638638
double lat = context.parser().doubleValue();
639-
while ((token = context.parser().nextToken()) != XContentParser.Token.END_ARRAY) {
640-
641-
}
639+
while ((token = context.parser().nextToken()) != XContentParser.Token.END_ARRAY);
642640
parse(context, sparse.reset(lat, lon), null);
643641
} else {
644642
while (token != XContentParser.Token.END_ARRAY) {

core/src/main/java/org/elasticsearch/index/mapper/geo/GeoShapeFieldMapper.java

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
import org.apache.lucene.spatial.prefix.tree.QuadPrefixTree;
3030
import org.apache.lucene.spatial.prefix.tree.SpatialPrefixTree;
3131
import org.elasticsearch.Version;
32+
import org.elasticsearch.common.Explicit;
3233
import org.elasticsearch.common.Strings;
3334
import org.elasticsearch.common.geo.GeoUtils;
3435
import org.elasticsearch.common.geo.SpatialStrategy;
@@ -41,6 +42,8 @@
4142
import org.elasticsearch.index.mapper.MappedFieldType;
4243
import org.elasticsearch.index.mapper.Mapper;
4344
import org.elasticsearch.index.mapper.MapperParsingException;
45+
import org.elasticsearch.index.mapper.MergeMappingException;
46+
import org.elasticsearch.index.mapper.MergeResult;
4447
import org.elasticsearch.index.mapper.ParseContext;
4548

4649
import java.io.IOException;
@@ -49,6 +52,7 @@
4952
import java.util.Map;
5053
import java.util.Objects;
5154

55+
import static org.elasticsearch.common.xcontent.support.XContentMapValues.nodeBooleanValue;
5256
import static org.elasticsearch.index.mapper.MapperBuilders.geoShapeField;
5357

5458

@@ -81,6 +85,7 @@ public static class Names {
8185
public static final String DISTANCE_ERROR_PCT = "distance_error_pct";
8286
public static final String ORIENTATION = "orientation";
8387
public static final String STRATEGY = "strategy";
88+
public static final String COERCE = "coerce";
8489
}
8590

8691
public static class Defaults {
@@ -90,6 +95,7 @@ public static class Defaults {
9095
public static final int QUADTREE_LEVELS = GeoUtils.quadTreeLevelsForPrecision("50m");
9196
public static final double LEGACY_DISTANCE_ERROR_PCT = 0.025d;
9297
public static final Orientation ORIENTATION = Orientation.RIGHT;
98+
public static final Explicit<Boolean> COERCE = new Explicit<>(false, false);
9399

94100
public static final MappedFieldType FIELD_TYPE = new GeoShapeFieldType();
95101

@@ -108,6 +114,8 @@ public static class Defaults {
108114

109115
public static class Builder extends FieldMapper.Builder<Builder, GeoShapeFieldMapper> {
110116

117+
private Boolean coerce;
118+
111119
public Builder(String name) {
112120
super(name, Defaults.FIELD_TYPE);
113121
}
@@ -116,6 +124,21 @@ public GeoShapeFieldType fieldType() {
116124
return (GeoShapeFieldType)fieldType;
117125
}
118126

127+
public Builder coerce(boolean coerce) {
128+
this.coerce = coerce;
129+
return builder;
130+
}
131+
132+
protected Explicit<Boolean> coerce(BuilderContext context) {
133+
if (coerce != null) {
134+
return new Explicit<>(coerce, true);
135+
}
136+
if (context.indexSettings() != null) {
137+
return new Explicit<>(context.indexSettings().getAsBoolean("index.mapping.coerce", Defaults.COERCE.value()), false);
138+
}
139+
return Defaults.COERCE;
140+
}
141+
119142
@Override
120143
public GeoShapeFieldMapper build(BuilderContext context) {
121144
GeoShapeFieldType geoShapeFieldType = (GeoShapeFieldType)fieldType;
@@ -130,7 +153,8 @@ public GeoShapeFieldMapper build(BuilderContext context) {
130153
}
131154
setupFieldType(context);
132155

133-
return new GeoShapeFieldMapper(name, fieldType, context.indexSettings(), multiFieldsBuilder.build(this, context), copyTo);
156+
return new GeoShapeFieldMapper(name, fieldType, coerce(context), context.indexSettings(), multiFieldsBuilder.build(this,
157+
context), copyTo);
134158
}
135159
}
136160

@@ -161,6 +185,9 @@ public Mapper.Builder parse(String name, Map<String, Object> node, ParserContext
161185
} else if (Names.STRATEGY.equals(fieldName)) {
162186
builder.fieldType().setStrategyName(fieldNode.toString());
163187
iterator.remove();
188+
} else if (Names.COERCE.equals(fieldName)) {
189+
builder.coerce(nodeBooleanValue(fieldNode));
190+
iterator.remove();
164191
}
165192
}
166193
return builder;
@@ -246,7 +273,7 @@ public void freeze() {
246273
termStrategy.setDistErrPct(distanceErrorPct());
247274
defaultStrategy = resolveStrategy(strategyName);
248275
}
249-
276+
250277
@Override
251278
public void checkCompatibility(MappedFieldType fieldType, List<String> conflicts, boolean strict) {
252279
super.checkCompatibility(fieldType, conflicts, strict);
@@ -357,8 +384,12 @@ public String value(Object value) {
357384

358385
}
359386

360-
public GeoShapeFieldMapper(String simpleName, MappedFieldType fieldType, Settings indexSettings, MultiFields multiFields, CopyTo copyTo) {
387+
protected Explicit<Boolean> coerce;
388+
389+
public GeoShapeFieldMapper(String simpleName, MappedFieldType fieldType, Explicit<Boolean> coerce, Settings indexSettings,
390+
MultiFields multiFields, CopyTo copyTo) {
361391
super(simpleName, fieldType, Defaults.FIELD_TYPE, indexSettings, multiFields, copyTo);
392+
this.coerce = coerce;
362393
}
363394

364395
@Override
@@ -397,6 +428,21 @@ public Mapper parse(ParseContext context) throws IOException {
397428
protected void parseCreateField(ParseContext context, List<Field> fields) throws IOException {
398429
}
399430

431+
@Override
432+
public void merge(Mapper mergeWith, MergeResult mergeResult) throws MergeMappingException {
433+
super.merge(mergeWith, mergeResult);
434+
if (!this.getClass().equals(mergeWith.getClass())) {
435+
return;
436+
}
437+
438+
GeoShapeFieldMapper gsfm = (GeoShapeFieldMapper)mergeWith;
439+
if (mergeResult.simulate() == false && mergeResult.hasConflicts() == false) {
440+
if (gsfm.coerce.explicit()) {
441+
this.coerce = gsfm.coerce;
442+
}
443+
}
444+
}
445+
400446
@Override
401447
protected void doXContentBody(XContentBuilder builder, boolean includeDefaults, Params params) throws IOException {
402448
builder.field("type", contentType());
@@ -419,6 +465,13 @@ protected void doXContentBody(XContentBuilder builder, boolean includeDefaults,
419465
if (includeDefaults || fieldType().orientation() != Defaults.ORIENTATION) {
420466
builder.field(Names.ORIENTATION, fieldType().orientation());
421467
}
468+
if (includeDefaults || coerce.explicit()) {
469+
builder.field("coerce", coerce.value());
470+
}
471+
}
472+
473+
public Explicit<Boolean> coerce() {
474+
return coerce;
422475
}
423476

424477
@Override

core/src/main/java/org/elasticsearch/index/query/GeoPolygonQueryParser.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,9 @@ public Query parse(QueryParseContext parseContext) throws IOException, QueryPars
9393
while ((token = parser.nextToken()) != Token.END_ARRAY) {
9494
shell.add(GeoUtils.parseGeoPoint(parser));
9595
}
96+
if (!shell.get(shell.size()-1).equals(shell.get(0))) {
97+
shell.add(shell.get(0));
98+
}
9699
} else {
97100
throw new QueryParsingException(parseContext, "[geo_polygon] query does not support [" + currentFieldName
98101
+ "]");

core/src/test/java/org/elasticsearch/index/mapper/geo/GeoShapeFieldMapperTests.java

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,41 @@ public void testOrientationParsing() throws IOException {
102102
assertThat(orientation, equalTo(ShapeBuilder.Orientation.CCW));
103103
}
104104

105+
/**
106+
* Test that orientation parameter correctly parses
107+
* @throws IOException
108+
*/
109+
public void testCoerceParsing() throws IOException {
110+
String mapping = XContentFactory.jsonBuilder().startObject().startObject("type1")
111+
.startObject("properties").startObject("location")
112+
.field("type", "geo_shape")
113+
.field("coerce", "true")
114+
.endObject().endObject()
115+
.endObject().endObject().string();
116+
117+
DocumentMapper defaultMapper = createIndex("test").mapperService().documentMapperParser().parse(mapping);
118+
FieldMapper fieldMapper = defaultMapper.mappers().getMapper("location");
119+
assertThat(fieldMapper, instanceOf(GeoShapeFieldMapper.class));
120+
121+
boolean coerce = ((GeoShapeFieldMapper)fieldMapper).coerce().value();
122+
assertThat(coerce, equalTo(true));
123+
124+
// explicit false coerce test
125+
mapping = XContentFactory.jsonBuilder().startObject().startObject("type1")
126+
.startObject("properties").startObject("location")
127+
.field("type", "geo_shape")
128+
.field("coerce", "false")
129+
.endObject().endObject()
130+
.endObject().endObject().string();
131+
132+
defaultMapper = createIndex("test2").mapperService().documentMapperParser().parse(mapping);
133+
fieldMapper = defaultMapper.mappers().getMapper("location");
134+
assertThat(fieldMapper, instanceOf(GeoShapeFieldMapper.class));
135+
136+
coerce = ((GeoShapeFieldMapper)fieldMapper).coerce().value();
137+
assertThat(coerce, equalTo(false));
138+
}
139+
105140
@Test
106141
public void testGeohashConfiguration() throws IOException {
107142
String mapping = XContentFactory.jsonBuilder().startObject().startObject("type1")

core/src/test/java/org/elasticsearch/search/geo/GeoPolygonTests.java

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,8 @@
2727
import org.junit.Test;
2828

2929
import static org.elasticsearch.common.xcontent.XContentFactory.jsonBuilder;
30+
import static org.elasticsearch.index.query.QueryBuilders.boolQuery;
3031
import static org.elasticsearch.index.query.QueryBuilders.geoPolygonQuery;
31-
import static org.elasticsearch.index.query.QueryBuilders.filteredQuery;
32-
import static org.elasticsearch.index.query.QueryBuilders.matchAllQuery;
3332
import static org.elasticsearch.test.hamcrest.ElasticsearchAssertions.assertAcked;
3433
import static org.elasticsearch.test.hamcrest.ElasticsearchAssertions.assertHitCount;
3534
import static org.hamcrest.Matchers.anyOf;
@@ -88,7 +87,7 @@ protected void setupSuiteScopeCluster() throws Exception {
8887
public void simplePolygonTest() throws Exception {
8988

9089
SearchResponse searchResponse = client().prepareSearch("test") // from NY
91-
.setQuery(filteredQuery(matchAllQuery(), geoPolygonQuery("location")
90+
.setQuery(boolQuery().must(geoPolygonQuery("location")
9291
.addPoint(40.7, -74.0)
9392
.addPoint(40.7, -74.1)
9493
.addPoint(40.8, -74.1)
@@ -101,4 +100,20 @@ public void simplePolygonTest() throws Exception {
101100
assertThat(hit.id(), anyOf(equalTo("1"), equalTo("3"), equalTo("4"), equalTo("5")));
102101
}
103102
}
103+
104+
@Test
105+
public void simpleUnclosedPolygon() throws Exception {
106+
SearchResponse searchResponse = client().prepareSearch("test") // from NY
107+
.setQuery(boolQuery().must(geoPolygonQuery("location")
108+
.addPoint(40.7, -74.0)
109+
.addPoint(40.7, -74.1)
110+
.addPoint(40.8, -74.1)
111+
.addPoint(40.8, -74.0)))
112+
.execute().actionGet();
113+
assertHitCount(searchResponse, 4);
114+
assertThat(searchResponse.getHits().hits().length, equalTo(4));
115+
for (SearchHit hit : searchResponse.getHits()) {
116+
assertThat(hit.id(), anyOf(equalTo("1"), equalTo("3"), equalTo("4"), equalTo("5")));
117+
}
118+
}
104119
}

0 commit comments

Comments
 (0)