Skip to content

Commit 5ed25f1

Browse files
committed
[GEO] Add WKT Support to GeoBoundingBoxQueryBuilder
Add WKT BBOX parsing support to GeoBoundingBoxQueryBuilder.
1 parent 18463e7 commit 5ed25f1

File tree

5 files changed

+181
-56
lines changed

5 files changed

+181
-56
lines changed

docs/reference/query-dsl/geo-bounding-box-query.asciidoc

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,31 @@ GET /_search
180180
--------------------------------------------------
181181
// CONSOLE
182182

183+
[float]
184+
===== Bounding Box as Well-Known Text (WKT)
185+
186+
[source,js]
187+
--------------------------------------------------
188+
GET /_search
189+
{
190+
"query": {
191+
"bool" : {
192+
"must" : {
193+
"match_all" : {}
194+
},
195+
"filter" : {
196+
"geo_bounding_box" : {
197+
"pin.location" : {
198+
"wkt" : "BBOX (-74.1, -71.12, 40.73, 40.01)"
199+
}
200+
}
201+
}
202+
}
203+
}
204+
}
205+
--------------------------------------------------
206+
// CONSOLE
207+
183208
[float]
184209
===== Geohash
185210

server/src/main/java/org/elasticsearch/common/geo/parsers/GeoWKTParser.java

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,12 @@ private GeoWKTParser() {}
6363

6464
public static ShapeBuilder parse(XContentParser parser)
6565
throws IOException, ElasticsearchParseException {
66+
return parseExpectedType(parser, null);
67+
}
68+
69+
/** throws an exception if the parsed geometry type does not match the expected shape type */
70+
public static ShapeBuilder parseExpectedType(XContentParser parser, final GeoShapeType shapeType)
71+
throws IOException, ElasticsearchParseException {
6672
FastStringReader reader = new FastStringReader(parser.text());
6773
try {
6874
// setup the tokenizer; configured to read words w/o numbers
@@ -77,7 +83,7 @@ public static ShapeBuilder parse(XContentParser parser)
7783
tokenizer.wordChars('.', '.');
7884
tokenizer.whitespaceChars(0, ' ');
7985
tokenizer.commentChar('#');
80-
ShapeBuilder builder = parseGeometry(tokenizer);
86+
ShapeBuilder builder = parseGeometry(tokenizer, shapeType);
8187
checkEOF(tokenizer);
8288
return builder;
8389
} finally {
@@ -86,8 +92,14 @@ public static ShapeBuilder parse(XContentParser parser)
8692
}
8793

8894
/** parse geometry from the stream tokenizer */
89-
private static ShapeBuilder parseGeometry(StreamTokenizer stream) throws IOException, ElasticsearchParseException {
95+
private static ShapeBuilder parseGeometry(StreamTokenizer stream, GeoShapeType shapeType)
96+
throws IOException, ElasticsearchParseException {
9097
final GeoShapeType type = GeoShapeType.forName(nextWord(stream));
98+
if (shapeType != null && shapeType != GeoShapeType.GEOMETRYCOLLECTION) {
99+
if (type.wktName().equals(shapeType.wktName()) == false) {
100+
throw new ElasticsearchParseException("Expected geometry type [{}] but found [{}]", shapeType, type);
101+
}
102+
}
91103
switch (type) {
92104
case POINT:
93105
return parsePoint(stream);
@@ -228,9 +240,10 @@ private static GeometryCollectionBuilder parseGeometryCollection(StreamTokenizer
228240
if (nextEmptyOrOpen(stream).equals(EMPTY)) {
229241
return null;
230242
}
231-
GeometryCollectionBuilder builder = new GeometryCollectionBuilder().shape(parseGeometry(stream));
243+
GeometryCollectionBuilder builder = new GeometryCollectionBuilder().shape(
244+
parseGeometry(stream, GeoShapeType.GEOMETRYCOLLECTION));
232245
while (nextCloserOrComma(stream).equals(COMMA)) {
233-
builder.shape(parseGeometry(stream));
246+
builder.shape(parseGeometry(stream, null));
234247
}
235248
return builder;
236249
}

server/src/main/java/org/elasticsearch/index/query/GeoBoundingBoxQueryBuilder.java

Lines changed: 83 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,10 @@
3131
import org.elasticsearch.common.ParsingException;
3232
import org.elasticsearch.common.geo.GeoHashUtils;
3333
import org.elasticsearch.common.geo.GeoPoint;
34+
import org.elasticsearch.common.geo.GeoShapeType;
3435
import org.elasticsearch.common.geo.GeoUtils;
36+
import org.elasticsearch.common.geo.builders.EnvelopeBuilder;
37+
import org.elasticsearch.common.geo.parsers.GeoWKTParser;
3538
import org.elasticsearch.common.io.stream.StreamInput;
3639
import org.elasticsearch.common.io.stream.StreamOutput;
3740
import org.elasticsearch.common.xcontent.XContentBuilder;
@@ -62,7 +65,6 @@ public class GeoBoundingBoxQueryBuilder extends AbstractQueryBuilder<GeoBounding
6265

6366
private static final ParseField TYPE_FIELD = new ParseField("type");
6467
private static final ParseField VALIDATION_METHOD_FIELD = new ParseField("validation_method");
65-
private static final ParseField FIELD_FIELD = new ParseField("field");
6668
private static final ParseField TOP_FIELD = new ParseField("top");
6769
private static final ParseField BOTTOM_FIELD = new ParseField("bottom");
6870
private static final ParseField LEFT_FIELD = new ParseField("left");
@@ -72,6 +74,8 @@ public class GeoBoundingBoxQueryBuilder extends AbstractQueryBuilder<GeoBounding
7274
private static final ParseField TOP_RIGHT_FIELD = new ParseField("top_right");
7375
private static final ParseField BOTTOM_LEFT_FIELD = new ParseField("bottom_left");
7476
private static final ParseField IGNORE_UNMAPPED_FIELD = new ParseField("ignore_unmapped");
77+
private static final ParseField WKT_FIELD = new ParseField("wkt");
78+
7579

7680
/** Name of field holding geo coordinates to compute the bounding box on.*/
7781
private final String fieldName;
@@ -378,68 +382,25 @@ protected void doXContent(XContentBuilder builder, Params params) throws IOExcep
378382
public static GeoBoundingBoxQueryBuilder fromXContent(XContentParser parser) throws IOException {
379383
String fieldName = null;
380384

381-
double top = Double.NaN;
382-
double bottom = Double.NaN;
383-
double left = Double.NaN;
384-
double right = Double.NaN;
385-
386385
float boost = AbstractQueryBuilder.DEFAULT_BOOST;
387386
String queryName = null;
388387
String currentFieldName = null;
389388
XContentParser.Token token;
390389
GeoValidationMethod validationMethod = null;
391390
boolean ignoreUnmapped = DEFAULT_IGNORE_UNMAPPED;
392391

393-
GeoPoint sparse = new GeoPoint();
394-
392+
Rectangle bbox = null;
395393
String type = "memory";
396394

397395
while ((token = parser.nextToken()) != XContentParser.Token.END_OBJECT) {
398396
if (token == XContentParser.Token.FIELD_NAME) {
399397
currentFieldName = parser.currentName();
400398
} else if (token == XContentParser.Token.START_OBJECT) {
401-
fieldName = currentFieldName;
402-
403-
while ((token = parser.nextToken()) != XContentParser.Token.END_OBJECT) {
404-
if (token == XContentParser.Token.FIELD_NAME) {
405-
currentFieldName = parser.currentName();
406-
token = parser.nextToken();
407-
if (FIELD_FIELD.match(currentFieldName)) {
408-
fieldName = parser.text();
409-
} else if (TOP_FIELD.match(currentFieldName)) {
410-
top = parser.doubleValue();
411-
} else if (BOTTOM_FIELD.match(currentFieldName)) {
412-
bottom = parser.doubleValue();
413-
} else if (LEFT_FIELD.match(currentFieldName)) {
414-
left = parser.doubleValue();
415-
} else if (RIGHT_FIELD.match(currentFieldName)) {
416-
right = parser.doubleValue();
417-
} else {
418-
if (TOP_LEFT_FIELD.match(currentFieldName)) {
419-
GeoUtils.parseGeoPoint(parser, sparse);
420-
top = sparse.getLat();
421-
left = sparse.getLon();
422-
} else if (BOTTOM_RIGHT_FIELD.match(currentFieldName)) {
423-
GeoUtils.parseGeoPoint(parser, sparse);
424-
bottom = sparse.getLat();
425-
right = sparse.getLon();
426-
} else if (TOP_RIGHT_FIELD.match(currentFieldName)) {
427-
GeoUtils.parseGeoPoint(parser, sparse);
428-
top = sparse.getLat();
429-
right = sparse.getLon();
430-
} else if (BOTTOM_LEFT_FIELD.match(currentFieldName)) {
431-
GeoUtils.parseGeoPoint(parser, sparse);
432-
bottom = sparse.getLat();
433-
left = sparse.getLon();
434-
} else {
435-
throw new ElasticsearchParseException("failed to parse [{}] query. unexpected field [{}]",
436-
NAME, currentFieldName);
437-
}
438-
}
439-
} else {
440-
throw new ElasticsearchParseException("failed to parse [{}] query. field name expected but [{}] found",
441-
NAME, token);
442-
}
399+
try {
400+
bbox = parseBoundingBox(parser);
401+
fieldName = currentFieldName;
402+
} catch (Exception e) {
403+
throw new ElasticsearchParseException("failed to parse [{}] query. [{}]", NAME, e.getMessage());
443404
}
444405
} else if (token.isValue()) {
445406
if (AbstractQueryBuilder.NAME_FIELD.match(currentFieldName)) {
@@ -459,8 +420,13 @@ public static GeoBoundingBoxQueryBuilder fromXContent(XContentParser parser) thr
459420
}
460421
}
461422

462-
final GeoPoint topLeft = sparse.reset(top, left); //just keep the object
463-
final GeoPoint bottomRight = new GeoPoint(bottom, right);
423+
if (bbox == null) {
424+
throw new ElasticsearchParseException("failed to parse [{}] query. bounding box not provided", NAME);
425+
}
426+
427+
final GeoPoint topLeft = new GeoPoint(bbox.maxLat, bbox.minLon); //just keep the object
428+
final GeoPoint bottomRight = new GeoPoint(bbox.minLat, bbox.maxLon);
429+
464430
GeoBoundingBoxQueryBuilder builder = new GeoBoundingBoxQueryBuilder(fieldName);
465431
builder.setCorners(topLeft, bottomRight);
466432
builder.queryName(queryName);
@@ -493,4 +459,69 @@ protected int doHashCode() {
493459
public String getWriteableName() {
494460
return NAME;
495461
}
462+
463+
public static Rectangle parseBoundingBox(XContentParser parser) throws IOException, ElasticsearchParseException {
464+
XContentParser.Token token = parser.currentToken();
465+
if (token != XContentParser.Token.START_OBJECT) {
466+
throw new ElasticsearchParseException("failed to parse bounding box. Expected start object but found [{}]", token);
467+
}
468+
469+
double top = Double.NaN;
470+
double bottom = Double.NaN;
471+
double left = Double.NaN;
472+
double right = Double.NaN;
473+
474+
String currentFieldName;
475+
GeoPoint sparse = new GeoPoint();
476+
EnvelopeBuilder envelope = null;
477+
478+
while ((token = parser.nextToken()) != XContentParser.Token.END_OBJECT) {
479+
if (token == XContentParser.Token.FIELD_NAME) {
480+
currentFieldName = parser.currentName();
481+
token = parser.nextToken();
482+
if (WKT_FIELD.match(currentFieldName)) {
483+
envelope = (EnvelopeBuilder)(GeoWKTParser.parseExpectedType(parser, GeoShapeType.ENVELOPE));
484+
} else if (TOP_FIELD.match(currentFieldName)) {
485+
top = parser.doubleValue();
486+
} else if (BOTTOM_FIELD.match(currentFieldName)) {
487+
bottom = parser.doubleValue();
488+
} else if (LEFT_FIELD.match(currentFieldName)) {
489+
left = parser.doubleValue();
490+
} else if (RIGHT_FIELD.match(currentFieldName)) {
491+
right = parser.doubleValue();
492+
} else {
493+
if (TOP_LEFT_FIELD.match(currentFieldName)) {
494+
GeoUtils.parseGeoPoint(parser, sparse);
495+
top = sparse.getLat();
496+
left = sparse.getLon();
497+
} else if (BOTTOM_RIGHT_FIELD.match(currentFieldName)) {
498+
GeoUtils.parseGeoPoint(parser, sparse);
499+
bottom = sparse.getLat();
500+
right = sparse.getLon();
501+
} else if (TOP_RIGHT_FIELD.match(currentFieldName)) {
502+
GeoUtils.parseGeoPoint(parser, sparse);
503+
top = sparse.getLat();
504+
right = sparse.getLon();
505+
} else if (BOTTOM_LEFT_FIELD.match(currentFieldName)) {
506+
GeoUtils.parseGeoPoint(parser, sparse);
507+
bottom = sparse.getLat();
508+
left = sparse.getLon();
509+
} else {
510+
throw new ElasticsearchParseException("failed to parse bounding box. unexpected field [{}]", currentFieldName);
511+
}
512+
}
513+
} else {
514+
throw new ElasticsearchParseException("failed to parse bounding box. field name expected but [{}] found", token);
515+
}
516+
}
517+
if (envelope != null) {
518+
if ((Double.isNaN(top) || Double.isNaN(bottom) || Double.isNaN(left) || Double.isNaN(right)) == false) {
519+
throw new ElasticsearchParseException("failed to parse bounding box. Conflicting definition found "
520+
+ "using well-known text and explicit corners.");
521+
}
522+
org.locationtech.spatial4j.shape.Rectangle r = envelope.build();
523+
return new Rectangle(r.getMinY(), r.getMaxY(), r.getMinX(), r.getMaxX());
524+
}
525+
return new Rectangle(bottom, top, left, right);
526+
}
496527
}

server/src/test/java/org/elasticsearch/common/geo/GeoWKTShapeParserTests.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
import org.elasticsearch.common.geo.parsers.GeoWKTParser;
4040
import org.elasticsearch.common.xcontent.XContentBuilder;
4141
import org.elasticsearch.common.xcontent.XContentFactory;
42+
import org.elasticsearch.common.xcontent.XContentParser;
4243
import org.elasticsearch.test.geo.RandomShapeGenerator;
4344
import org.locationtech.spatial4j.exception.InvalidShapeException;
4445
import org.locationtech.spatial4j.shape.Rectangle;
@@ -51,6 +52,8 @@
5152
import java.util.List;
5253

5354
import static org.elasticsearch.common.geo.builders.ShapeBuilder.SPATIAL_CONTEXT;
55+
import static org.hamcrest.Matchers.containsString;
56+
import static org.hamcrest.Matchers.hasToString;
5457

5558
/**
5659
* Tests for {@code GeoWKTShapeParser}
@@ -252,4 +255,13 @@ public void testParseGeometryCollection() throws IOException {
252255
assertExpected(gcb.build(), gcb);
253256
}
254257
}
258+
259+
public void testUnexpectedShapeException() throws IOException {
260+
XContentBuilder builder = toWKTContent(new PointBuilder(-1, 2), false);
261+
XContentParser parser = createParser(builder);
262+
parser.nextToken();
263+
ElasticsearchParseException e = expectThrows(ElasticsearchParseException.class,
264+
() -> GeoWKTParser.parseExpectedType(parser, GeoShapeType.POLYGON));
265+
assertThat(e, hasToString(containsString("Expected geometry type [polygon] but found [point]")));
266+
}
255267
}

server/src/test/java/org/elasticsearch/index/query/GeoBoundingBoxQueryBuilderTests.java

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -406,6 +406,50 @@ public void testFromJson() throws IOException {
406406
assertEquals(json, GeoExecType.MEMORY, parsed.type());
407407
}
408408

409+
public void testFromWKT() throws IOException {
410+
String wkt =
411+
"{\n" +
412+
" \"geo_bounding_box\" : {\n" +
413+
" \"pin.location\" : {\n" +
414+
" \"wkt\" : \"BBOX (-74.1, -71.12, 40.73, 40.01)\"\n" +
415+
" },\n" +
416+
" \"validation_method\" : \"STRICT\",\n" +
417+
" \"type\" : \"MEMORY\",\n" +
418+
" \"ignore_unmapped\" : false,\n" +
419+
" \"boost\" : 1.0\n" +
420+
" }\n" +
421+
"}";
422+
423+
// toXContent generates the query in geojson only; for now we need to test against the expected
424+
// geojson generated content
425+
String expectedJson =
426+
"{\n" +
427+
" \"geo_bounding_box\" : {\n" +
428+
" \"pin.location\" : {\n" +
429+
" \"top_left\" : [ -74.1, 40.73 ],\n" +
430+
" \"bottom_right\" : [ -71.12, 40.01 ]\n" +
431+
" },\n" +
432+
" \"validation_method\" : \"STRICT\",\n" +
433+
" \"type\" : \"MEMORY\",\n" +
434+
" \"ignore_unmapped\" : false,\n" +
435+
" \"boost\" : 1.0\n" +
436+
" }\n" +
437+
"}";
438+
439+
// parse with wkt
440+
GeoBoundingBoxQueryBuilder parsed = (GeoBoundingBoxQueryBuilder) parseQuery(wkt);
441+
// check the builder's generated geojson content against the expected json output
442+
checkGeneratedJson(expectedJson, parsed);
443+
double delta = 0d;
444+
assertEquals(expectedJson, "pin.location", parsed.fieldName());
445+
assertEquals(expectedJson, -74.1, parsed.topLeft().getLon(), delta);
446+
assertEquals(expectedJson, 40.73, parsed.topLeft().getLat(), delta);
447+
assertEquals(expectedJson, -71.12, parsed.bottomRight().getLon(), delta);
448+
assertEquals(expectedJson, 40.01, parsed.bottomRight().getLat(), delta);
449+
assertEquals(expectedJson, 1.0, parsed.boost(), delta);
450+
assertEquals(expectedJson, GeoExecType.MEMORY, parsed.type());
451+
}
452+
409453
@Override
410454
public void testMustRewrite() throws IOException {
411455
assumeTrue("test runs only when at least a type is registered", getCurrentTypes().length > 0);

0 commit comments

Comments
 (0)