diff --git a/docs/reference/images/spatial/error_distance.png b/docs/reference/images/spatial/error_distance.png new file mode 100644 index 0000000000000..a3274d778c047 Binary files /dev/null and b/docs/reference/images/spatial/error_distance.png differ diff --git a/docs/reference/ingest/ingest-node.asciidoc b/docs/reference/ingest/ingest-node.asciidoc index f955f25bdc2fb..e0f5254f8f0cd 100644 --- a/docs/reference/ingest/ingest-node.asciidoc +++ b/docs/reference/ingest/ingest-node.asciidoc @@ -839,6 +839,7 @@ See {plugins}/ingest.html[Ingest plugins] for information about the available in include::processors/append.asciidoc[] include::processors/bytes.asciidoc[] +include::processors/circle.asciidoc[] include::processors/convert.asciidoc[] include::processors/date.asciidoc[] include::processors/date-index-name.asciidoc[] diff --git a/docs/reference/ingest/processors/circle.asciidoc b/docs/reference/ingest/processors/circle.asciidoc new file mode 100644 index 0000000000000..97120fe918154 --- /dev/null +++ b/docs/reference/ingest/processors/circle.asciidoc @@ -0,0 +1,165 @@ +[role="xpack"] +[testenv="basic"] +[[ingest-circle-processor]] +=== Circle Processor +Converts circle definitions of shapes to regular polygons which approximate them. + +[[circle-processor-options]] +.Circle Processor Options +[options="header"] +|====== +| Name | Required | Default | Description +| `field` | yes | - | The string-valued field to trim whitespace from +| `target_field` | no | `field` | The field to assign the polygon shape to, by default `field` is updated in-place +| `ignore_missing` | no | `false` | If `true` and `field` does not exist, the processor quietly exits without modifying the document +| `error_distance` | yes | - | The difference between the resulting inscribed distance from center to side and the circle's radius (measured in meters for `geo_shape`, unit-less for `shape`) +| `shape_type` | yes | - | which field mapping type is to be used when processing the circle: `geo_shape` or `shape` +include::common-options.asciidoc[] +|====== + + +image:images/spatial/error_distance.png[] + +[source,js] +-------------------------------------------------- +PUT circles +{ + "mappings": { + "properties": { + "circle": { + "type": "geo_shape" + } + } + } +} + +PUT _ingest/pipeline/polygonize_circles +{ + "description": "translate circle to polygon", + "processors": [ + { + "circle": { + "field": "circle", + "error_distance": 28.0, + "shape_type": "geo_shape" + } + } + ] +} +-------------------------------------------------- +// CONSOLE + +Using the above pipeline, we can attempt to index a document into the `circles` index. +The circle can be represented as either a WKT circle or a GeoJSON circle. The resulting +polygon will be represented and indexed using the same format as the input circle. WKT will +be translated to a WKT polygon, and GeoJSON circles will be translated to GeoJSON polygons. + +==== Example: Circle defined in Well Known Text + +In this example a circle defined in WKT format is indexed + +[source,js] +-------------------------------------------------- +PUT circles/_doc/1?pipeline=polygonize_circles +{ + "circle": "CIRCLE (30 10 40)" +} + +GET circles/_doc/1 +-------------------------------------------------- +// CONSOLE +// TEST[continued] + +The response from the above index request: + +[source,js] +-------------------------------------------------- +{ + "found": true, + "_index": "circles", + "_type": "_doc", + "_id": "1", + "_version": 1, + "_seq_no": 22, + "_primary_term": 1, + "_source": { + "circle": "polygon ((30.000365257263184 10.0, 30.000111397193788 10.00034284530941, 29.999706043744222 10.000213571721195, 29.999706043744222 9.999786428278805, 30.000111397193788 9.99965715469059, 30.000365257263184 10.0))" + } +} +-------------------------------------------------- +// TESTRESPONSE[s/"_seq_no": \d+/"_seq_no" : $body._seq_no/ s/"_primary_term": 1/"_primary_term" : $body._primary_term/] + +==== Example: Circle defined in GeoJSON + +In this example a circle defined in GeoJSON format is indexed + +[source,js] +-------------------------------------------------- +PUT circles/_doc/2?pipeline=polygonize_circles +{ + "circle": { + "type": "circle", + "radius": "40m", + "coordinates": [30, 10] + } +} + +GET circles/_doc/2 +-------------------------------------------------- +// CONSOLE +// TEST[continued] + +The response from the above index request: + +[source,js] +-------------------------------------------------- +{ + "found": true, + "_index": "circles", + "_type": "_doc", + "_id": "2", + "_version": 1, + "_seq_no": 22, + "_primary_term": 1, + "_source": { + "circle": { + "coordinates": [ + [ + [30.000365257263184, 10.0], + [30.000111397193788, 10.00034284530941], + [29.999706043744222, 10.000213571721195], + [29.999706043744222, 9.999786428278805], + [30.000111397193788, 9.99965715469059], + [30.000365257263184, 10.0] + ] + ], + "type": "polygon" + } + } +} +-------------------------------------------------- +// TESTRESPONSE[s/"_seq_no": \d+/"_seq_no" : $body._seq_no/ s/"_primary_term": 1/"_primary_term" : $body._primary_term/] + + +==== Notes on Accuracy + +Accuracy of the polygon that represents the circle is defined as `error_distance`. The smaller this +difference is, the closer to a perfect circle the polygon is. + +Below is a table that aims to help capture how the radius of the circle affects the resulting number of sides +of the polygon given different inputs. + +The minimum number of sides is `4` and the maximum is `1000`. + +[[circle-processor-accuracy]] +.Circle Processor Accuracy +[options="header"] +|====== +| error_distance | radius in meters | number of sides of polygon +| 1.00 | 1.0 | 4 +| 1.00 | 10.0 | 14 +| 1.00 | 100.0 | 45 +| 1.00 | 1000.0 | 141 +| 1.00 | 10000.0 | 445 +| 1.00 | 100000.0 | 1000 +|====== diff --git a/server/src/main/java/org/elasticsearch/ingest/ConfigurationUtils.java b/server/src/main/java/org/elasticsearch/ingest/ConfigurationUtils.java index 29ae578a64371..c725157d8de0c 100644 --- a/server/src/main/java/org/elasticsearch/ingest/ConfigurationUtils.java +++ b/server/src/main/java/org/elasticsearch/ingest/ConfigurationUtils.java @@ -189,6 +189,26 @@ public static Integer readIntProperty(String processorType, String processorTag, } } + /** + * Returns and removes the specified property from the specified configuration map. + * + * If the property value isn't of type int a {@link ElasticsearchParseException} is thrown. + * If the property is missing an {@link ElasticsearchParseException} is thrown + */ + public static Double readDoubleProperty(String processorType, String processorTag, Map configuration, + String propertyName) { + Object value = configuration.remove(propertyName); + if (value == null) { + throw newConfigurationException(processorType, processorTag, propertyName, "required property is missing"); + } + try { + return Double.parseDouble(value.toString()); + } catch (Exception e) { + throw newConfigurationException(processorType, processorTag, propertyName, + "property cannot be converted to a double [" + value.toString() + "]"); + } + } + /** * Returns and removes the specified property of type list from the specified configuration map. * diff --git a/x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/SpatialPlugin.java b/x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/SpatialPlugin.java index b91a6f335ed47..b15d3e620d203 100644 --- a/x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/SpatialPlugin.java +++ b/x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/SpatialPlugin.java @@ -9,7 +9,9 @@ import org.elasticsearch.action.ActionResponse; import org.elasticsearch.common.settings.Settings; import org.elasticsearch.index.mapper.Mapper; +import org.elasticsearch.ingest.Processor; import org.elasticsearch.plugins.ActionPlugin; +import org.elasticsearch.plugins.IngestPlugin; import org.elasticsearch.plugins.MapperPlugin; import org.elasticsearch.plugins.Plugin; import org.elasticsearch.plugins.SearchPlugin; @@ -17,6 +19,7 @@ import org.elasticsearch.xpack.core.action.XPackUsageFeatureAction; import org.elasticsearch.xpack.spatial.index.mapper.ShapeFieldMapper; import org.elasticsearch.xpack.spatial.index.query.ShapeQueryBuilder; +import org.elasticsearch.xpack.spatial.ingest.CircleProcessor; import java.util.Arrays; import java.util.Collections; @@ -26,7 +29,7 @@ import static java.util.Collections.singletonList; -public class SpatialPlugin extends Plugin implements ActionPlugin, MapperPlugin, SearchPlugin { +public class SpatialPlugin extends Plugin implements ActionPlugin, MapperPlugin, SearchPlugin, IngestPlugin { public SpatialPlugin(Settings settings) { } @@ -49,4 +52,9 @@ public Map getMappers() { public List> getQueries() { return singletonList(new QuerySpec<>(ShapeQueryBuilder.NAME, ShapeQueryBuilder::new, ShapeQueryBuilder::fromXContent)); } + + @Override + public Map getProcessors(Processor.Parameters parameters) { + return Map.of(CircleProcessor.TYPE, new CircleProcessor.Factory()); + } } diff --git a/x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/SpatialUtils.java b/x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/SpatialUtils.java new file mode 100644 index 0000000000000..49ace55986027 --- /dev/null +++ b/x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/SpatialUtils.java @@ -0,0 +1,101 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +package org.elasticsearch.xpack.spatial; + +import org.apache.lucene.util.SloppyMath; +import org.elasticsearch.geometry.Circle; +import org.elasticsearch.geometry.LinearRing; +import org.elasticsearch.geometry.Polygon; +import org.elasticsearch.index.mapper.GeoShapeIndexer; + +/** + * Utility class for storing different helpful re-usable spatial functions + */ +public class SpatialUtils { + + private SpatialUtils() {} + + /** + * Makes an n-gon, centered at the provided circle's center, and each vertex approximately + * {@link Circle#getRadiusMeters()} away from the center. + * + * This does not split the polygon across the date-line. Relies on {@link GeoShapeIndexer} to + * split prepare polygon for indexing. + * + * Adapted from from org.apache.lucene.geo.GeoTestUtil + * */ + public static Polygon createRegularGeoShapePolygon(Circle circle, int gons) { + double[][] result = new double[2][]; + result[0] = new double[gons+1]; + result[1] = new double[gons+1]; + for(int i=0; i circle.getRadiusMeters()) { + // too big + factor -= step; + if (last == 1) { + step /= 2.0; + } + last = -1; + } else if (distanceMeters < circle.getRadiusMeters()) { + // too small + factor += step; + if (last == -1) { + step /= 2.0; + } + last = 1; + } + } + } + + // close poly + result[0][gons] = result[0][0]; + result[1][gons] = result[1][0]; + return new Polygon(new LinearRing(result[0], result[1])); + } + + /** + * Makes an n-gon, centered at the provided circle's center. This assumes + * distance measured in cartesian geometry. + **/ + public static Polygon createRegularShapePolygon(Circle circle, int gons) { + double[][] result = new double[2][]; + result[0] = new double[gons+1]; + result[1] = new double[gons+1]; + for(int i=0; i valueWrapper; + if (obj instanceof Map || obj instanceof String) { + valueWrapper = Map.of("shape", obj); + } else { + throw new IllegalArgumentException("field [" + field + "] must be a WKT Circle or a GeoJSON Circle value"); + } + + MapXContentParser parser = new MapXContentParser(NamedXContentRegistry.EMPTY, + DeprecationHandler.THROW_UNSUPPORTED_OPERATION, valueWrapper, XContentType.JSON); + try { + parser.nextToken(); // START_OBJECT + parser.nextToken(); // "shape" field key + parser.nextToken(); // shape value + GeometryFormat geometryFormat = PARSER.geometryFormat(parser); + Geometry geometry = geometryFormat.fromXContent(parser); + if (ShapeType.CIRCLE.equals(geometry.type())) { + Circle circle = (Circle) geometry; + int numSides = numSides(circle.getRadiusMeters()); + final Geometry polygonizedCircle; + switch (circleShapeFieldType) { + case GEO_SHAPE: + polygonizedCircle = SpatialUtils.createRegularGeoShapePolygon(circle, numSides); + break; + case SHAPE: + polygonizedCircle = SpatialUtils.createRegularShapePolygon(circle, numSides); + break; + default: + throw new IllegalStateException("invalid shape_type [" + circleShapeFieldType + "]"); + } + XContentBuilder newValueBuilder = XContentFactory.jsonBuilder().startObject().field("val"); + geometryFormat.toXContent(polygonizedCircle, newValueBuilder, ToXContent.EMPTY_PARAMS); + newValueBuilder.endObject(); + Map newObj = XContentHelper.convertToMap( + BytesReference.bytes(newValueBuilder), true, XContentType.JSON).v2(); + ingestDocument.setFieldValue(targetField, newObj.get("val")); + } else { + throw new IllegalArgumentException("found [" + geometry.type() + "] instead of circle"); + } + } catch (Exception e) { + throw new IllegalArgumentException("invalid circle definition", e); + } + + return ingestDocument; + } + + @Override + public String getType() { + return TYPE; + } + + String field() { + return field; + } + + String targetField() { + return targetField; + } + + double errorDistance() { + return errorDistance; + } + + CircleShapeFieldType shapeType() { + return circleShapeFieldType; + } + + int numSides(double radiusMeters) { + int val = (int) Math.ceil(2 * Math.PI / Math.acos(1 - errorDistance / radiusMeters)); + return Math.min(MAXIMUM_NUMBER_OF_SIDES, Math.max(MINIMUM_NUMBER_OF_SIDES, val)); + } + + + public static final class Factory implements Processor.Factory { + + public CircleProcessor create(Map registry, String processorTag, Map config) { + String field = ConfigurationUtils.readStringProperty(TYPE, processorTag, config, "field"); + String targetField = ConfigurationUtils.readStringProperty(TYPE, processorTag, config, "target_field", field); + boolean ignoreMissing = ConfigurationUtils.readBooleanProperty(TYPE, processorTag, config, "ignore_missing", false); + double radiusDistance = Math.abs(ConfigurationUtils.readDoubleProperty(TYPE, processorTag, config, "error_distance")); + CircleShapeFieldType circleFieldType = CircleShapeFieldType.parse( + ConfigurationUtils.readStringProperty(TYPE, processorTag, config, "shape_type")); + return new CircleProcessor(processorTag, field, targetField, ignoreMissing, radiusDistance, circleFieldType); + } + } + + enum CircleShapeFieldType { + SHAPE, GEO_SHAPE; + + public static CircleShapeFieldType parse(String value) { + EnumSet validValues = EnumSet.allOf(CircleShapeFieldType.class); + try { + return valueOf(value.toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("illegal [shape_type] value [" + value + "]. valid values are " + + Arrays.toString(validValues.toArray())); + } + } + } +} diff --git a/x-pack/plugin/spatial/src/test/java/org/elasticsearch/xpack/spatial/SpatialUtilsTests.java b/x-pack/plugin/spatial/src/test/java/org/elasticsearch/xpack/spatial/SpatialUtilsTests.java new file mode 100644 index 0000000000000..df773fbb7c419 --- /dev/null +++ b/x-pack/plugin/spatial/src/test/java/org/elasticsearch/xpack/spatial/SpatialUtilsTests.java @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +package org.elasticsearch.xpack.spatial; + +import org.apache.lucene.util.SloppyMath; +import org.elasticsearch.geometry.Circle; +import org.elasticsearch.geometry.LinearRing; +import org.elasticsearch.geometry.Polygon; +import org.elasticsearch.test.ESTestCase; + +import static org.hamcrest.Matchers.closeTo; +import static org.hamcrest.Matchers.equalTo; + +public class SpatialUtilsTests extends ESTestCase { + + public void testCreateRegularGeoShapePolygon() { + double lon = randomDoubleBetween(-20, 20, true); + double lat = randomDoubleBetween(-20, 20, true); + double radiusMeters = randomDoubleBetween(10, 10000, true); + Circle circle = new Circle(lon, lat, radiusMeters); + int numSides = randomIntBetween(4, 1000); + Polygon polygon = SpatialUtils.createRegularGeoShapePolygon(circle, numSides); + LinearRing outerShell = polygon.getPolygon(); + int numPoints = outerShell.length(); + + // check no holes created + assertThat(polygon.getNumberOfHoles(), equalTo(0)); + // check there are numSides edges + assertThat(numPoints, equalTo(numSides + 1)); + // check that all the points are about a radius away from the center + for (int i = 0; i < numPoints ; i++) { + double actualDistance = SloppyMath + .haversinMeters(circle.getY(), circle.getX(), outerShell.getY(i), outerShell.getX(i)); + assertThat(actualDistance, closeTo(radiusMeters, 0.1)); + } + } + + public void testCreateRegularShapePolygon() { + double x = randomDoubleBetween(-20, 20, true); + double y = randomDoubleBetween(-20, 20, true); + double radius = randomDoubleBetween(10, 10000, true); + Circle circle = new Circle(x, y, radius); + int numSides = randomIntBetween(4, 1000); + Polygon polygon = SpatialUtils.createRegularShapePolygon(circle, numSides); + LinearRing outerShell = polygon.getPolygon(); + int numPoints = outerShell.length(); + + // check no holes created + assertThat(polygon.getNumberOfHoles(), equalTo(0)); + // check there are numSides edges + assertThat(numPoints, equalTo(numSides + 1)); + // check that all the points are about a radius away from the center + for (int i = 0; i < numPoints ; i++) { + double deltaX = circle.getX() - outerShell.getX(i); + double deltaY = circle.getY() - outerShell.getY(i); + double distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY); + assertThat(distance, closeTo(radius, 0.0001)); + } + } +} diff --git a/x-pack/plugin/spatial/src/test/java/org/elasticsearch/xpack/spatial/ingest/CircleProcessorFactoryTests.java b/x-pack/plugin/spatial/src/test/java/org/elasticsearch/xpack/spatial/ingest/CircleProcessorFactoryTests.java new file mode 100644 index 0000000000000..be4544c10305e --- /dev/null +++ b/x-pack/plugin/spatial/src/test/java/org/elasticsearch/xpack/spatial/ingest/CircleProcessorFactoryTests.java @@ -0,0 +1,94 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +package org.elasticsearch.xpack.spatial.ingest; + +import org.elasticsearch.ElasticsearchParseException; +import org.elasticsearch.test.ESTestCase; + +import org.junit.Before; + +import java.util.HashMap; +import java.util.Map; + +import static org.hamcrest.Matchers.equalTo; + +public class CircleProcessorFactoryTests extends ESTestCase { + + private CircleProcessor.Factory factory; + + @Before + public void init() { + factory = new CircleProcessor.Factory(); + } + + public void testCreateGeoShape() { + Map config = new HashMap<>(); + config.put("field", "field1"); + config.put("error_distance", 0.002); + config.put("shape_type", "geo_shape"); + String processorTag = randomAlphaOfLength(10); + CircleProcessor processor = factory.create(null, processorTag, config); + assertThat(processor.getTag(), equalTo(processorTag)); + assertThat(processor.field(), equalTo("field1")); + assertThat(processor.targetField(), equalTo("field1")); + assertThat(processor.errorDistance(), equalTo(0.002)); + assertThat(processor.shapeType(), equalTo(CircleProcessor.CircleShapeFieldType.GEO_SHAPE)); + } + + public void testCreateShape() { + Map config = new HashMap<>(); + config.put("field", "field1"); + config.put("error_distance", 0.002); + config.put("shape_type", "shape"); + String processorTag = randomAlphaOfLength(10); + CircleProcessor processor = factory.create(null, processorTag, config); + assertThat(processor.getTag(), equalTo(processorTag)); + assertThat(processor.field(), equalTo("field1")); + assertThat(processor.targetField(), equalTo("field1")); + assertThat(processor.errorDistance(), equalTo(0.002)); + assertThat(processor.shapeType(), equalTo(CircleProcessor.CircleShapeFieldType.SHAPE)); + } + + public void testCreateInvalidShapeType() { + Map config = new HashMap<>(); + config.put("field", "field1"); + config.put("error_distance", 0.002); + config.put("shape_type", "invalid"); + String processorTag = randomAlphaOfLength(10); + IllegalArgumentException e = expectThrows(IllegalArgumentException.class, () -> factory.create(null, processorTag, config)); + assertThat(e.getMessage(), equalTo("illegal [shape_type] value [invalid]. valid values are [SHAPE, GEO_SHAPE]")); + } + + public void testCreateMissingField() { + Map config = new HashMap<>(); + String processorTag = randomAlphaOfLength(10); + ElasticsearchParseException e = expectThrows(ElasticsearchParseException.class, () -> factory.create(null, processorTag, config)); + assertThat(e.getMessage(), equalTo("[field] required property is missing")); + } + + public void testCreateWithTargetField() { + Map config = new HashMap<>(); + config.put("field", "field1"); + config.put("target_field", "other"); + config.put("error_distance", 0.002); + config.put("shape_type", "geo_shape"); + String processorTag = randomAlphaOfLength(10); + CircleProcessor processor = factory.create(null, processorTag, config); + assertThat(processor.getTag(), equalTo(processorTag)); + assertThat(processor.field(), equalTo("field1")); + assertThat(processor.targetField(), equalTo("other")); + assertThat(processor.errorDistance(), equalTo(0.002)); + assertThat(processor.shapeType(), equalTo(CircleProcessor.CircleShapeFieldType.GEO_SHAPE)); + } + + public void testCreateWithNoErrorDistanceDefined() { + Map config = new HashMap<>(); + config.put("field", "field1"); + String processorTag = randomAlphaOfLength(10); + ElasticsearchParseException e = expectThrows(ElasticsearchParseException.class, () -> factory.create(null, processorTag, config)); + assertThat(e.getMessage(), equalTo("[error_distance] required property is missing")); + } +} diff --git a/x-pack/plugin/spatial/src/test/java/org/elasticsearch/xpack/spatial/ingest/CircleProcessorTests.java b/x-pack/plugin/spatial/src/test/java/org/elasticsearch/xpack/spatial/ingest/CircleProcessorTests.java new file mode 100644 index 0000000000000..55f3e84ed56fe --- /dev/null +++ b/x-pack/plugin/spatial/src/test/java/org/elasticsearch/xpack/spatial/ingest/CircleProcessorTests.java @@ -0,0 +1,277 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +package org.elasticsearch.xpack.spatial.ingest; + +import org.apache.lucene.document.Document; +import org.apache.lucene.index.IndexReader; +import org.apache.lucene.index.IndexableField; +import org.apache.lucene.index.RandomIndexWriter; +import org.apache.lucene.search.IndexSearcher; +import org.apache.lucene.search.Query; +import org.apache.lucene.store.Directory; +import org.elasticsearch.common.bytes.BytesReference; +import org.elasticsearch.common.collect.Tuple; +import org.elasticsearch.common.geo.GeoJson; +import org.elasticsearch.common.geo.ShapeRelation; +import org.elasticsearch.common.xcontent.ToXContent; +import org.elasticsearch.common.xcontent.XContentBuilder; +import org.elasticsearch.common.xcontent.XContentFactory; +import org.elasticsearch.common.xcontent.XContentHelper; +import org.elasticsearch.common.xcontent.XContentType; +import org.elasticsearch.geometry.Circle; +import org.elasticsearch.geometry.Geometry; +import org.elasticsearch.geometry.Point; +import org.elasticsearch.geometry.Polygon; +import org.elasticsearch.geometry.utils.StandardValidator; +import org.elasticsearch.geometry.utils.WellKnownText; +import org.elasticsearch.index.mapper.GeoShapeFieldMapper; +import org.elasticsearch.index.mapper.GeoShapeIndexer; +import org.elasticsearch.index.mapper.MappedFieldType; +import org.elasticsearch.index.query.QueryShardContext; +import org.elasticsearch.index.query.VectorGeoShapeQueryProcessor; +import org.elasticsearch.ingest.IngestDocument; +import org.elasticsearch.ingest.RandomDocumentPicks; +import org.elasticsearch.test.ESTestCase; +import org.elasticsearch.xpack.spatial.SpatialUtils; +import org.elasticsearch.xpack.spatial.index.mapper.ShapeFieldMapper; +import org.elasticsearch.xpack.spatial.index.mapper.ShapeIndexer; +import org.elasticsearch.xpack.spatial.index.query.ShapeQueryProcessor; + +import java.io.IOException; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.elasticsearch.ingest.IngestDocumentMatcher.assertIngestDocument; +import static org.elasticsearch.xpack.spatial.ingest.CircleProcessor.CircleShapeFieldType; +import static org.elasticsearch.xpack.spatial.ingest.CircleProcessor.CircleShapeFieldType.GEO_SHAPE; +import static org.elasticsearch.xpack.spatial.ingest.CircleProcessor.CircleShapeFieldType.SHAPE; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.instanceOf; +import static org.mockito.Matchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class CircleProcessorTests extends ESTestCase { + private static final WellKnownText WKT = new WellKnownText(true, new StandardValidator(true)); + + public void testNumSides() { + double radiusDistanceMeters = randomDoubleBetween(0.01, 6371000, true); + CircleShapeFieldType shapeType = randomFrom(SHAPE, GEO_SHAPE); + CircleProcessor processor = new CircleProcessor("tag", "field", "field", false, radiusDistanceMeters, shapeType); + + // radius is same as error distance + assertThat(processor.numSides(radiusDistanceMeters), equalTo(4)); + // radius is much smaller than error distance + assertThat(processor.numSides(0), equalTo(4)); + // radius is much larger than error distance + assertThat(processor.numSides(Math.pow(radiusDistanceMeters, 100)), equalTo(1000)); + // radius is 5 times longer than error distance + assertThat(processor.numSides(5*radiusDistanceMeters), equalTo(10)); + + } + + public void testFieldNotFound() throws Exception { + CircleProcessor processor = new CircleProcessor("tag", "field", "field", false, 10, GEO_SHAPE); + IngestDocument ingestDocument = RandomDocumentPicks.randomIngestDocument(random(), new HashMap<>()); + Exception e = expectThrows(Exception.class, () -> processor.execute(ingestDocument)); + assertThat(e.getMessage(), containsString("not present as part of path [field]")); + } + + public void testFieldNotFoundWithIgnoreMissing() throws Exception { + CircleProcessor processor = new CircleProcessor("tag", "field", "field", true, 10, GEO_SHAPE); + IngestDocument originalIngestDocument = RandomDocumentPicks.randomIngestDocument(random(), new HashMap<>()); + IngestDocument ingestDocument = new IngestDocument(originalIngestDocument); + processor.execute(ingestDocument); + assertIngestDocument(originalIngestDocument, ingestDocument); + } + + public void testNullValue() throws Exception { + CircleProcessor processor = new CircleProcessor("tag", "field", "field", false, 10, GEO_SHAPE); + IngestDocument ingestDocument = RandomDocumentPicks.randomIngestDocument(random(), Collections.singletonMap("field", null)); + Exception e = expectThrows(Exception.class, () -> processor.execute(ingestDocument)); + assertThat(e.getMessage(), equalTo("field [field] is null, cannot process it.")); + } + + public void testNullValueWithIgnoreMissing() throws Exception { + CircleProcessor processor = new CircleProcessor("tag", "field", "field", true, 10, GEO_SHAPE); + IngestDocument originalIngestDocument = RandomDocumentPicks.randomIngestDocument(random(), Collections.singletonMap("field", null)); + IngestDocument ingestDocument = new IngestDocument(originalIngestDocument); + processor.execute(ingestDocument); + assertIngestDocument(originalIngestDocument, ingestDocument); + } + + @SuppressWarnings("unchecked") + public void testJson() throws IOException { + Circle circle = new Circle(101.0, 1.0, 10); + HashMap map = new HashMap<>(); + HashMap circleMap = new HashMap<>(); + circleMap.put("type", "Circle"); + circleMap.put("coordinates", List.of(circle.getLon(), circle.getLat())); + circleMap.put("radius", circle.getRadiusMeters() + "m"); + map.put("field", circleMap); + Geometry expectedPoly = SpatialUtils.createRegularGeoShapePolygon(circle, 4); + assertThat(expectedPoly, instanceOf(Polygon.class)); + IngestDocument ingestDocument = new IngestDocument(map, Collections.emptyMap()); + CircleProcessor processor = new CircleProcessor("tag", "field", "field", false, 10, GEO_SHAPE); + processor.execute(ingestDocument); + Map polyMap = ingestDocument.getFieldValue("field", Map.class); + XContentBuilder builder = XContentFactory.jsonBuilder(); + GeoJson.toXContent(expectedPoly, builder, ToXContent.EMPTY_PARAMS); + Tuple> expected = XContentHelper.convertToMap(BytesReference.bytes(builder), + true, XContentType.JSON); + assertThat(polyMap, equalTo(expected.v2())); + } + + public void testWKT() { + Circle circle = new Circle(101.0, 0.0, 2); + HashMap map = new HashMap<>(); + map.put("field", WKT.toWKT(circle)); + Geometry expectedPoly = SpatialUtils.createRegularGeoShapePolygon(circle, 4); + IngestDocument ingestDocument = new IngestDocument(map, Collections.emptyMap()); + CircleProcessor processor = new CircleProcessor("tag", "field", "field",false, 2, GEO_SHAPE); + processor.execute(ingestDocument); + String polyString = ingestDocument.getFieldValue("field", String.class); + assertThat(polyString, equalTo(WKT.toWKT(expectedPoly))); + } + + public void testInvalidWKT() { + HashMap map = new HashMap<>(); + map.put("field", "invalid"); + IngestDocument ingestDocument = new IngestDocument(map, Collections.emptyMap()); + CircleProcessor processor = new CircleProcessor("tag", "field", "field", false, 10, GEO_SHAPE); + IllegalArgumentException e = expectThrows(IllegalArgumentException.class, () -> processor.execute(ingestDocument)); + assertThat(e.getMessage(), equalTo("invalid circle definition")); + map.put("field", "POINT (30 10)"); + e = expectThrows(IllegalArgumentException.class, () -> processor.execute(ingestDocument)); + assertThat(e.getMessage(), equalTo("invalid circle definition")); + } + + public void testMissingField() { + IngestDocument ingestDocument = new IngestDocument(new HashMap<>(), Collections.emptyMap()); + CircleProcessor processor = new CircleProcessor("tag", "field", "field", false, 10, GEO_SHAPE); + IllegalArgumentException e = expectThrows(IllegalArgumentException.class, () -> processor.execute(ingestDocument)); + assertThat(e.getMessage(), equalTo("field [field] not present as part of path [field]")); + } + + public void testInvalidType() { + Map field = new HashMap<>(); + field.put("coordinates", List.of(100, 100)); + field.put("radius", "10m"); + Map map = new HashMap<>(); + map.put("field", field); + IngestDocument ingestDocument = new IngestDocument(map, Collections.emptyMap()); + CircleProcessor processor = new CircleProcessor("tag", "field", "field", false, 10, GEO_SHAPE); + + for (Object value : new Object[] { null, 4.0, "not_circle"}) { + field.put("type", value); + IllegalArgumentException e = expectThrows(IllegalArgumentException.class, () -> processor.execute(ingestDocument)); + assertThat(e.getMessage(), equalTo("invalid circle definition")); + } + } + + public void testInvalidCoordinates() { + Map field = new HashMap<>(); + field.put("type", "circle"); + field.put("radius", "10m"); + Map map = new HashMap<>(); + map.put("field", field); + IngestDocument ingestDocument = new IngestDocument(map, Collections.emptyMap()); + CircleProcessor processor = new CircleProcessor("tag", "field", "field", false, 10, GEO_SHAPE); + + for (Object value : new Object[] { null, "not_circle"}) { + field.put("coordinates", value); + IllegalArgumentException e = expectThrows(IllegalArgumentException.class, () -> processor.execute(ingestDocument)); + assertThat(e.getMessage(), equalTo("invalid circle definition")); + } + } + + public void testInvalidRadius() { + Map field = new HashMap<>(); + field.put("type", "circle"); + field.put("coordinates", List.of(100.0, 1.0)); + Map map = new HashMap<>(); + map.put("field", field); + IngestDocument ingestDocument = new IngestDocument(map, Collections.emptyMap()); + CircleProcessor processor = new CircleProcessor("tag", "field", "field", false, 10, GEO_SHAPE); + + for (Object value : new Object[] { null, "NotNumber", "10.0fs"}) { + field.put("radius", value); + IllegalArgumentException e = expectThrows(IllegalArgumentException.class, () -> processor.execute(ingestDocument)); + assertThat(e.getMessage(), equalTo("invalid circle definition")); + } + } + + public void testGeoShapeQueryAcrossDateline() throws IOException { + String fieldName = "circle"; + Circle circle = new Circle(179.999746, 67.1726, randomDoubleBetween(1000, 300000, true)); + int numSides = randomIntBetween(4, 1000); + Geometry geometry = SpatialUtils.createRegularGeoShapePolygon(circle, numSides); + + MappedFieldType shapeType = new GeoShapeFieldMapper.GeoShapeFieldType(); + shapeType.setHasDocValues(false); + shapeType.setName(fieldName); + + VectorGeoShapeQueryProcessor processor = new VectorGeoShapeQueryProcessor(); + QueryShardContext mockedContext = mock(QueryShardContext.class); + when(mockedContext.fieldMapper(any())).thenReturn(shapeType); + Query sameShapeQuery = processor.process(geometry, fieldName, ShapeRelation.INTERSECTS, mockedContext); + Query pointOnDatelineQuery = processor.process(new Point(180, circle.getLat()), fieldName, + ShapeRelation.INTERSECTS, mockedContext); + + try (Directory dir = newDirectory(); RandomIndexWriter w = new RandomIndexWriter(random(), dir)) { + Document doc = new Document(); + GeoShapeIndexer indexer = new GeoShapeIndexer(true, fieldName); + Geometry normalized = indexer.prepareForIndexing(geometry); + for (IndexableField field : indexer.indexShape(null, normalized)) { + doc.add(field); + } + w.addDocument(doc); + + try (IndexReader reader = w.getReader()) { + IndexSearcher searcher = new IndexSearcher(reader); + assertThat(searcher.search(sameShapeQuery, 1).totalHits.value, equalTo(1L)); + assertThat(searcher.search(pointOnDatelineQuery, 1).totalHits.value, equalTo(1L)); + } + } + } + + public void testShapeQuery() throws IOException { + String fieldName = "circle"; + Circle circle = new Circle(0, 0, 10); + int numSides = randomIntBetween(4, 1000); + Geometry geometry = SpatialUtils.createRegularShapePolygon(circle, numSides); + + MappedFieldType shapeType = new ShapeFieldMapper.ShapeFieldType(); + shapeType.setHasDocValues(false); + shapeType.setName(fieldName); + + ShapeQueryProcessor processor = new ShapeQueryProcessor(); + QueryShardContext mockedContext = mock(QueryShardContext.class); + when(mockedContext.fieldMapper(any())).thenReturn(shapeType); + Query sameShapeQuery = processor.process(geometry, fieldName, ShapeRelation.INTERSECTS, mockedContext); + Query centerPointQuery = processor.process(new Point(circle.getLon(), circle.getLat()), fieldName, + ShapeRelation.INTERSECTS, mockedContext); + + try (Directory dir = newDirectory(); RandomIndexWriter w = new RandomIndexWriter(random(), dir)) { + Document doc = new Document(); + ShapeIndexer indexer = new ShapeIndexer(fieldName); + Geometry normalized = indexer.prepareForIndexing(geometry); + for (IndexableField field : indexer.indexShape(null, normalized)) { + doc.add(field); + } + w.addDocument(doc); + + try (IndexReader reader = w.getReader()) { + IndexSearcher searcher = new IndexSearcher(reader); + assertThat(searcher.search(sameShapeQuery, 1).totalHits.value, equalTo(1L)); + assertThat(searcher.search(centerPointQuery, 1).totalHits.value, equalTo(1L)); + } + } + } +}