Bladeren bron

new geo_grid query to be used with geogrid aggregations (#86596)

Query that allows users to define a query where the input is the key of the bucket and it will match the 
documents inside that bucket.
Ignacio Vera 3 jaren geleden
bovenliggende
commit
e6b4097fc8
21 gewijzigde bestanden met toevoegingen van 1874 en 90 verwijderingen
  1. 391 0
      docs/reference/query-dsl/geo-grid-query.asciidoc
  2. 13 7
      docs/reference/query-dsl/geo-queries.asciidoc
  3. 4 3
      docs/reference/query-dsl/geo-shape-query.asciidoc
  4. 1 0
      server/src/main/java/module-info.java
  5. 26 11
      server/src/main/java/org/elasticsearch/search/aggregations/bucket/geogrid/GeoTileUtils.java
  6. 232 0
      x-pack/plugin/spatial/src/internalClusterTest/java/org/elasticsearch/xpack/spatial/search/GeoGridAggAndQueryConsistencyIT.java
  7. 5 3
      x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/SpatialPlugin.java
  8. 5 6
      x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/index/fielddata/GeoShapeValues.java
  9. 6 10
      x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/index/fielddata/Tile2DVisitor.java
  10. 391 0
      x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/index/query/GeoGridQueryBuilder.java
  11. 215 0
      x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/index/query/H3LatLonGeometry.java
  12. 11 1
      x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/AbstractGeoHashGridTiler.java
  13. 15 3
      x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/AbstractGeoTileGridTiler.java
  14. 284 0
      x-pack/plugin/spatial/src/test/java/org/elasticsearch/xpack/spatial/index/query/GeoGridQueryBuilderTests.java
  15. 93 0
      x-pack/plugin/spatial/src/test/java/org/elasticsearch/xpack/spatial/index/query/H3LatLonGeometryTests.java
  16. 14 3
      x-pack/plugin/spatial/src/test/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/GeoHashTilerTests.java
  17. 10 34
      x-pack/plugin/spatial/src/test/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/GeoShapeGeoGridTestCase.java
  18. 21 3
      x-pack/plugin/spatial/src/test/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/GeoShapeGeoHashGridAggregatorTests.java
  19. 21 2
      x-pack/plugin/spatial/src/test/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/GeoShapeGeoTileGridAggregatorTests.java
  20. 18 4
      x-pack/plugin/spatial/src/test/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/GeoTileTilerTests.java
  21. 98 0
      x-pack/plugin/src/yamlRestTest/resources/rest-api-spec/test/spatial/90_geo_grid_query.yml

+ 391 - 0
docs/reference/query-dsl/geo-grid-query.asciidoc

@@ -0,0 +1,391 @@
+[[query-dsl-geo-grid-query]]
+=== Geo-grid query
+++++
+<titleabbrev>Geo-grid</titleabbrev>
+++++
+
+Matches <<geo-point,`geo_point`>> and <<geo-shape,`geo_shape`>> values that
+intersect a grid cell from a GeoGrid aggregation.
+
+The query is designed to match the documents that fall inside a bucket of a geogrid aggregation by
+providing the key of the bucket. For geohash and geotile grids, the query can be used for geo_point
+and geo_shape fields. For geo_hex grid, it can only be used for geo_point fields.
+
+[discrete]
+[[geo-grid-query-ex]]
+==== Example
+Assume the following the following documents are indexed:
+
+[source,console]
+--------------------------------------------------
+PUT /my_locations
+{
+  "mappings": {
+    "properties": {
+      "location": {
+        "type": "geo_point"
+      }
+    }
+  }
+}
+
+PUT /my_locations/_doc/1?refresh
+{
+  "location" : "POINT(4.912350 52.374081)",
+  "city": "Amsterdam",
+  "name": "NEMO Science Museum"
+}
+
+PUT /my_locations/_doc/2?refresh
+{
+  "location" : "POINT(4.405200 51.222900)",
+  "city": "Antwerp",
+  "name": "Letterenhuis"
+}
+
+PUT /my_locations/_doc/3?refresh
+{
+  "location" : "POINT(2.336389 48.861111)",
+  "city": "Paris",
+  "name": "Musée du Louvre"
+}
+
+--------------------------------------------------
+// TESTSETUP
+
+[[query-dsl-geo-grid-query-geohash]]
+==== geohash grid
+
+Using a geohash_grid aggregation, it is possible to group documents depending on their geohash value:
+
+[source,console]
+--------------------------------------------------
+GET /my_locations/_search
+{
+  "size" : 0,
+  "aggs" : {
+     "grouped" : {
+        "geohash_grid" : {
+           "field" : "location",
+           "precision" : 2
+        }
+     }
+  }
+}
+--------------------------------------------------
+
+
+[source,console-result]
+--------------------------------------------------
+{
+  "took" : 10,
+  "timed_out" : false,
+  "_shards" : {
+    "total" : 1,
+    "successful" : 1,
+    "skipped" : 0,
+    "failed" : 0
+  },
+  "hits" : {
+    "total" : {
+      "value" : 3,
+      "relation" : "eq"
+    },
+    "max_score" : null,
+    "hits" : [ ]
+  },
+  "aggregations" : {
+    "grouped" : {
+      "buckets" : [
+        {
+          "key" : "u1",
+          "doc_count" : 2
+        },
+        {
+          "key" : "u0",
+          "doc_count" : 1
+        }
+      ]
+    }
+  }
+}
+--------------------------------------------------
+// TESTRESPONSE[s/"took" : 10/"took" : $body.took/]
+
+We can extract the documents on one of those buckets by executing a geo_grid query
+using the bucket key with the following syntax:
+
+[source,console]
+--------------------------------------------------
+GET /my_locations/_search
+{
+  "query": {
+    "geo_grid" :{
+      "location" : {
+        "geohash" : "u0"
+      }
+    }
+  }
+}
+--------------------------------------------------
+
+
+[source,console-result]
+--------------------------------------------------
+{
+  "took" : 1,
+  "timed_out" : false,
+  "_shards" : {
+    "total" : 1,
+    "successful" : 1,
+    "skipped" : 0,
+    "failed" : 0
+  },
+  "hits" : {
+    "total" : {
+      "value" : 1,
+      "relation" : "eq"
+    },
+    "max_score" : 1.0,
+    "hits" : [
+      {
+        "_index" : "my_locations",
+        "_id" : "3",
+        "_score" : 1.0,
+        "_source" : {
+          "location" : "POINT(2.336389 48.861111)",
+          "city" : "Paris",
+          "name" : "Musée du Louvre"
+        }
+      }
+    ]
+  }
+}
+--------------------------------------------------
+// TESTRESPONSE[s/"took" : 1/"took" : $body.took/]
+
+
+[[query-dsl-geo-grid-query-geotile]]
+==== geotile grid
+
+Using a geotile_grid aggregation, it is possible to group documents depending on their geotile value:
+
+[source,console]
+--------------------------------------------------
+GET /my_locations/_search
+{
+  "size" : 0,
+  "aggs" : {
+     "grouped" : {
+        "geotile_grid" : {
+           "field" : "location",
+           "precision" : 6
+        }
+     }
+  }
+}
+--------------------------------------------------
+
+
+[source,console-result]
+--------------------------------------------------
+{
+  "took" : 1,
+  "timed_out" : false,
+  "_shards" : {
+    "total" : 1,
+    "successful" : 1,
+    "skipped" : 0,
+    "failed" : 0
+  },
+  "hits" : {
+    "total" : {
+      "value" : 3,
+      "relation" : "eq"
+    },
+    "max_score" : null,
+    "hits" : [ ]
+  },
+  "aggregations" : {
+    "grouped" : {
+      "buckets" : [
+        {
+          "key" : "6/32/21",
+          "doc_count" : 2
+        },
+        {
+          "key" : "6/32/22",
+          "doc_count" : 1
+        }
+      ]
+    }
+  }
+}
+
+--------------------------------------------------
+// TESTRESPONSE[s/"took" : 1/"took" : $body.took/]
+
+We can extract the documents on one of those buckets by executing a geo_grid query
+using the bucket key with the following syntax:
+
+[source,console]
+--------------------------------------------------
+GET /my_locations/_search
+{
+  "query": {
+    "geo_grid" :{
+      "location" : {
+        "geotile" : "6/32/22"
+      }
+    }
+  }
+}
+--------------------------------------------------
+
+
+[source,console-result]
+--------------------------------------------------
+{
+  "took" : 1,
+  "timed_out" : false,
+  "_shards" : {
+    "total" : 1,
+    "successful" : 1,
+    "skipped" : 0,
+    "failed" : 0
+  },
+  "hits" : {
+    "total" : {
+      "value" : 1,
+      "relation" : "eq"
+    },
+    "max_score" : 1.0,
+    "hits" : [
+      {
+        "_index" : "my_locations",
+        "_id" : "3",
+        "_score" : 1.0,
+        "_source" : {
+          "location" : "POINT(2.336389 48.861111)",
+          "city" : "Paris",
+          "name" : "Musée du Louvre"
+        }
+      }
+    ]
+  }
+}
+--------------------------------------------------
+// TESTRESPONSE[s/"took" : 1/"took" : $body.took/]
+
+
+[[query-dsl-geo-grid-query-geohex]]
+==== geohex grid
+
+Using a geohex_grid aggregation, it is possible to group documents depending on their geohex value:
+
+[source,console]
+--------------------------------------------------
+GET /my_locations/_search
+{
+  "size" : 0,
+  "aggs" : {
+     "grouped" : {
+        "geohex_grid" : {
+           "field" : "location",
+           "precision" : 1
+        }
+     }
+  }
+}
+--------------------------------------------------
+
+
+[source,console-result]
+--------------------------------------------------
+{
+  "took" : 2,
+  "timed_out" : false,
+  "_shards" : {
+    "total" : 1,
+    "successful" : 1,
+    "skipped" : 0,
+    "failed" : 0
+  },
+  "hits" : {
+    "total" : {
+      "value" : 3,
+      "relation" : "eq"
+    },
+    "max_score" : null,
+    "hits" : [ ]
+  },
+  "aggregations" : {
+    "grouped" : {
+      "buckets" : [
+        {
+          "key" : "81197ffffffffff",
+          "doc_count" : 2
+        },
+        {
+          "key" : "811fbffffffffff",
+          "doc_count" : 1
+        }
+      ]
+    }
+  }
+}
+
+--------------------------------------------------
+// TESTRESPONSE[s/"took" : 2/"took" : $body.took/]
+
+We can extract the documents on one of those buckets by executing a geo_grid query
+using the bucket key with the following syntax:
+
+[source,console]
+--------------------------------------------------
+GET /my_locations/_search
+{
+  "query": {
+    "geo_grid" :{
+      "location" : {
+        "geohex" : "811fbffffffffff"
+      }
+    }
+  }
+}
+--------------------------------------------------
+
+
+[source,console-result]
+--------------------------------------------------
+{
+  "took" : 26,
+  "timed_out" : false,
+  "_shards" : {
+    "total" : 1,
+    "successful" : 1,
+    "skipped" : 0,
+    "failed" : 0
+  },
+  "hits" : {
+    "total" : {
+      "value" : 1,
+      "relation" : "eq"
+    },
+    "max_score" : 1.0,
+    "hits" : [
+      {
+        "_index" : "my_locations",
+        "_id" : "3",
+        "_score" : 1.0,
+        "_source" : {
+          "location" : "POINT(2.336389 48.861111)",
+          "city" : "Paris",
+          "name" : "Musée du Louvre"
+        }
+      }
+    ]
+  }
+}
+--------------------------------------------------
+// TESTRESPONSE[s/"took" : 26/"took" : $body.took/]

+ 13 - 7
docs/reference/query-dsl/geo-queries.asciidoc

@@ -9,24 +9,30 @@ lines, circles, polygons, multi-polygons, etc.
 The queries in this group are:
 
 <<query-dsl-geo-bounding-box-query,`geo_bounding_box`>> query::
-Finds documents with geopoints that fall into the specified rectangle.
+Finds documents with geoshapes or geopoints which intersect the specified rectangle.
 
 <<query-dsl-geo-distance-query,`geo_distance`>> query::
-Finds documents with geopoints within the specified distance of a central point.
+Finds documents with geoshapes or geopoints within the specified distance of a central point.
+
+<<query-dsl-geo-grid-query,`geo_grid`>> query::
+Finds documents with:
+* Geoshapes or geopoints which intersect the specified geohash
+* Geoshapes or geopoints which intersect the specified map tile
+* Geopoints which intersect the specified H3 bin
 
 <<query-dsl-geo-polygon-query,`geo_polygon`>> query::
-Find documents with geopoints within the specified polygon.
+Find documents with geoshapes or geopoints which intersect the specified polygon.
 
 <<query-dsl-geo-shape-query,`geo_shape`>> query::
-Finds documents with:
-* Geoshapes which either intersect, are contained by, or do not intersect
-with the specified geoshape
-* Geopoints which intersect the specified geoshape
+Finds documents with geoshapes or geopoints which are related to the specified geoshape.
+Possible spatial relationships to specify are: intersects, contained, within and disjoint.
 
 include::geo-bounding-box-query.asciidoc[]
 
 include::geo-distance-query.asciidoc[]
 
+include::geo-grid-query.asciidoc[]
+
 include::geo-polygon-query.asciidoc[]
 
 include::geo-shape-query.asciidoc[]

+ 4 - 3
docs/reference/query-dsl/geo-shape-query.asciidoc

@@ -10,9 +10,10 @@ Requires the <<geo-shape,`geo_shape` mapping>> or the
 <<geo-point,`geo_point` mapping>>.
 
 The `geo_shape` query uses the same grid square representation as the
-`geo_shape` mapping to find documents that have a shape that intersects
-with the query shape. It will also use the same Prefix Tree configuration
-as defined for the field mapping.
+`geo_shape` mapping to find documents that have a shape that is related
+to the query shape, using a specified spatial relationship: either intersects,
+contained, within or disjoint.
+It will also use the same Prefix Tree configuration as defined for the field mapping.
 
 The query supports two ways of defining the query shape, either by
 providing a whole shape definition, or by referencing the name of a shape

+ 1 - 0
server/src/main/java/module-info.java

@@ -44,6 +44,7 @@ module org.elasticsearch.server {
     requires org.apache.lucene.queries;
     requires org.apache.lucene.queryparser;
     requires org.apache.lucene.sandbox;
+    requires org.apache.lucene.spatial3d;
     requires org.apache.lucene.suggest;
 
     exports org.elasticsearch;

+ 26 - 11
server/src/main/java/org/elasticsearch/search/aggregations/bucket/geogrid/GeoTileUtils.java

@@ -52,13 +52,13 @@ public final class GeoTileUtils {
      */
     public static final double LATITUDE_MASK = 85.0511287798066;
 
+    public static final int ENCODED_LATITUDE_MASK = GeoEncodingUtils.encodeLatitude(LATITUDE_MASK);
+    public static final int ENCODED_NEGATIVE_LATITUDE_MASK = GeoEncodingUtils.encodeLatitude(-LATITUDE_MASK);
     /**
      * Since shapes are encoded, their boundaries are to be compared to against the encoded/decoded values of <code>LATITUDE_MASK</code>
      */
-    public static final double NORMALIZED_LATITUDE_MASK = GeoEncodingUtils.decodeLatitude(GeoEncodingUtils.encodeLatitude(LATITUDE_MASK));
-    public static final double NORMALIZED_NEGATIVE_LATITUDE_MASK = GeoEncodingUtils.decodeLatitude(
-        GeoEncodingUtils.encodeLatitude(-LATITUDE_MASK)
-    );
+    public static final double NORMALIZED_LATITUDE_MASK = GeoEncodingUtils.decodeLatitude(ENCODED_LATITUDE_MASK);
+    public static final double NORMALIZED_NEGATIVE_LATITUDE_MASK = GeoEncodingUtils.decodeLatitude(ENCODED_NEGATIVE_LATITUDE_MASK);
 
     /**
      * Bit position of the zoom value within hash - zoom is stored in the most significant 6 bits of a long number.
@@ -252,18 +252,33 @@ public final class GeoTileUtils {
         return toBoundingBox(hashAsInts[1], hashAsInts[2], hashAsInts[0]);
     }
 
+    /**
+     * Decode a bucket key to a bounding box of the tile corners
+     */
     public static Rectangle toBoundingBox(int xTile, int yTile, int precision) {
         final double tiles = validateZXY(precision, xTile, yTile);
-        final double minN = Math.PI - (2.0 * Math.PI * (yTile + 1)) / tiles;
-        final double maxN = Math.PI - (2.0 * Math.PI * (yTile)) / tiles;
-        final double minY = Math.toDegrees(ESSloppyMath.atan(ESSloppyMath.sinh(minN)));
-        final double minX = ((xTile) / tiles * 360.0) - 180;
-        final double maxY = Math.toDegrees(ESSloppyMath.atan(ESSloppyMath.sinh(maxN)));
-        final double maxX = ((xTile + 1) / tiles * 360.0) - 180;
-
+        final double minY = tileToLat(yTile + 1, tiles);
+        final double minX = tileToLon(xTile, tiles);
+        final double maxY = tileToLat(yTile, tiles);
+        final double maxX = tileToLon(xTile + 1, tiles);
         return new Rectangle(minX, maxX, maxY, minY);
     }
 
+    /**
+     * Decode a xTile into its longitude value
+     */
+    public static double tileToLon(int xTile, double tiles) {
+        return (xTile / tiles * 360.0) - 180;
+    }
+
+    /**
+     * Decode a yTile into its latitude value
+     */
+    public static double tileToLat(int yTile, double tiles) {
+        final double n = Math.PI - (2.0 * Math.PI * yTile) / tiles;
+        return Math.toDegrees(ESSloppyMath.atan(ESSloppyMath.sinh(n)));
+    }
+
     /**
      * Validates Zoom, X, and Y values, and returns the total number of allowed tiles along the x/y axis.
      */

+ 232 - 0
x-pack/plugin/spatial/src/internalClusterTest/java/org/elasticsearch/xpack/spatial/search/GeoGridAggAndQueryConsistencyIT.java

@@ -0,0 +1,232 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+package org.elasticsearch.xpack.spatial.search;
+
+import org.elasticsearch.action.bulk.BulkRequestBuilder;
+import org.elasticsearch.action.index.IndexRequest;
+import org.elasticsearch.action.search.SearchResponse;
+import org.elasticsearch.common.geo.GeoBoundingBox;
+import org.elasticsearch.common.geo.GeoPoint;
+import org.elasticsearch.common.geo.GeoUtils;
+import org.elasticsearch.geo.GeometryTestUtils;
+import org.elasticsearch.geometry.Geometry;
+import org.elasticsearch.geometry.Point;
+import org.elasticsearch.geometry.Rectangle;
+import org.elasticsearch.geometry.utils.Geohash;
+import org.elasticsearch.geometry.utils.WellKnownText;
+import org.elasticsearch.h3.CellBoundary;
+import org.elasticsearch.h3.H3;
+import org.elasticsearch.index.mapper.GeoPointFieldMapper;
+import org.elasticsearch.index.query.QueryBuilder;
+import org.elasticsearch.plugins.Plugin;
+import org.elasticsearch.search.aggregations.bucket.geogrid.GeoGridAggregationBuilder;
+import org.elasticsearch.search.aggregations.bucket.geogrid.GeoHashGridAggregationBuilder;
+import org.elasticsearch.search.aggregations.bucket.geogrid.GeoTileGridAggregationBuilder;
+import org.elasticsearch.search.aggregations.bucket.geogrid.GeoTileUtils;
+import org.elasticsearch.search.aggregations.bucket.geogrid.InternalGeoGrid;
+import org.elasticsearch.search.aggregations.bucket.geogrid.InternalGeoGridBucket;
+import org.elasticsearch.test.ESIntegTestCase;
+import org.elasticsearch.xcontent.XContentBuilder;
+import org.elasticsearch.xcontent.XContentFactory;
+import org.elasticsearch.xcontent.XContentType;
+import org.elasticsearch.xpack.spatial.LocalStateSpatialPlugin;
+import org.elasticsearch.xpack.spatial.index.mapper.GeoShapeWithDocValuesFieldMapper;
+import org.elasticsearch.xpack.spatial.index.query.GeoGridQueryBuilder;
+import org.elasticsearch.xpack.spatial.search.aggregations.bucket.geogrid.GeoHexGridAggregationBuilder;
+import org.hamcrest.Matchers;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.function.BiFunction;
+import java.util.function.Function;
+import java.util.function.Supplier;
+
+public class GeoGridAggAndQueryConsistencyIT extends ESIntegTestCase {
+
+    @Override
+    protected boolean addMockGeoShapeFieldMapper() {
+        return false;
+    }
+
+    @Override
+    protected Collection<Class<? extends Plugin>> nodePlugins() {
+        return Collections.singleton(LocalStateSpatialPlugin.class);
+    }
+
+    public void testGeoPointGeoHash() throws IOException {
+        doTestGeohashGrid(GeoPointFieldMapper.CONTENT_TYPE, GeometryTestUtils::randomPoint);
+    }
+
+    public void testGeoPointGeoTile() throws IOException {
+        doTestGeotileGrid(
+            GeoPointFieldMapper.CONTENT_TYPE,
+            // just generate points on bounds
+            () -> randomValueOtherThanMany(
+                p -> p.getLat() > GeoTileUtils.NORMALIZED_LATITUDE_MASK || p.getLat() < GeoTileUtils.NORMALIZED_NEGATIVE_LATITUDE_MASK,
+                GeometryTestUtils::randomPoint
+            )
+        );
+    }
+
+    public void testGeoPointGeoHex() throws IOException {
+        doTestGeohexGrid(GeoPointFieldMapper.CONTENT_TYPE, GeometryTestUtils::randomPoint);
+    }
+
+    public void testGeoShapeGeoHash() throws IOException {
+        doTestGeohashGrid(GeoShapeWithDocValuesFieldMapper.CONTENT_TYPE, () -> GeometryTestUtils.randomGeometryWithoutCircle(0, false));
+    }
+
+    public void testGeoShapeGeoTile() throws IOException {
+        doTestGeotileGrid(GeoShapeWithDocValuesFieldMapper.CONTENT_TYPE, () -> GeometryTestUtils.randomGeometryWithoutCircle(0, false));
+    }
+
+    private void doTestGeohashGrid(String fieldType, Supplier<Geometry> randomGeometriesSupplier) throws IOException {
+        doTestGrid(
+            1,
+            Geohash.PRECISION,
+            fieldType,
+            (precision, point) -> Geohash.stringEncode(point.getLon(), point.getLat(), precision),
+            hash -> toPoints(Geohash.toBoundingBox(hash)),
+            Geohash::toBoundingBox,
+            GeoHashGridAggregationBuilder::new,
+            (s1, s2) -> new GeoGridQueryBuilder(s1).setGridId(GeoGridQueryBuilder.Grid.GEOHASH, s2),
+            randomGeometriesSupplier
+        );
+    }
+
+    private void doTestGeotileGrid(String fieldType, Supplier<Geometry> randomGeometriesSupplier) throws IOException {
+        doTestGrid(
+            0,
+            GeoTileUtils.MAX_ZOOM,
+            fieldType,
+            (precision, point) -> GeoTileUtils.stringEncode(GeoTileUtils.longEncode(point.getLon(), point.getLat(), precision)),
+            tile -> toPoints(GeoTileUtils.toBoundingBox(tile)),
+            GeoTileUtils::toBoundingBox,
+            GeoTileGridAggregationBuilder::new,
+            (s1, s2) -> new GeoGridQueryBuilder(s1).setGridId(GeoGridQueryBuilder.Grid.GEOTILE, s2),
+            randomGeometriesSupplier
+        );
+    }
+
+    private void doTestGeohexGrid(String fieldType, Supplier<Geometry> randomGeometriesSupplier) throws IOException {
+        doTestGrid(1, H3.MAX_H3_RES, fieldType, (precision, point) -> H3.geoToH3Address(point.getLat(), point.getLon(), precision), h3 -> {
+            final CellBoundary boundary = H3.h3ToGeoBoundary(h3);
+            final List<Point> points = new ArrayList<>(boundary.numPoints());
+            for (int i = 0; i < boundary.numPoints(); i++) {
+                points.add(new Point(boundary.getLatLon(i).getLonDeg(), boundary.getLatLon(i).getLatDeg()));
+            }
+            return points;
+        },
+            h3 -> new Rectangle(GeoUtils.MIN_LON, GeoUtils.MAX_LON, GeoUtils.MAX_LAT, GeoUtils.MAX_LAT),
+            GeoHexGridAggregationBuilder::new,
+            (s1, s2) -> new GeoGridQueryBuilder(s1).setGridId(GeoGridQueryBuilder.Grid.GEOHEX, s2),
+            randomGeometriesSupplier
+        );
+    }
+
+    private void doTestGrid(
+        int minPrecision,
+        int maxPrecision,
+        String fieldType,
+        BiFunction<Integer, Point, String> pointEncoder,
+        Function<String, List<Point>> toPoints,
+        Function<String, Rectangle> toBoundingBox,
+        Function<String, GeoGridAggregationBuilder> aggBuilder,
+        BiFunction<String, String, QueryBuilder> queryBuilder,
+        Supplier<Geometry> randomGeometriesSupplier
+    ) throws IOException {
+        XContentBuilder xcb = XContentFactory.jsonBuilder()
+            .startObject()
+            .startObject("properties")
+            .startObject("geometry")
+            .field("type", fieldType)
+            .endObject()
+            .endObject()
+            .endObject();
+        client().admin().indices().prepareCreate("test").setMapping(xcb).get();
+
+        Point queryPoint = GeometryTestUtils.randomPoint();
+        String[] tiles = new String[maxPrecision + 1];
+        for (int zoom = minPrecision; zoom < tiles.length; zoom++) {
+            tiles[zoom] = pointEncoder.apply(zoom, queryPoint);
+        }
+
+        BulkRequestBuilder builder = client().prepareBulk();
+        for (int zoom = minPrecision; zoom < tiles.length; zoom++) {
+            List<Point> edgePoints = toPoints.apply(tiles[zoom]);
+            String[] multiPoint = new String[edgePoints.size()];
+            for (int i = 0; i < edgePoints.size(); i++) {
+                String wkt = WellKnownText.toWKT(edgePoints.get(i));
+                String doc = "{\"geometry\" : \"" + wkt + "\"}";
+                builder.add(new IndexRequest("test").source(doc, XContentType.JSON));
+                multiPoint[i] = "\"" + wkt + "\"";
+            }
+            String doc = "{\"geometry\" : " + Arrays.toString(multiPoint) + "}";
+            builder.add(new IndexRequest("test").source(doc, XContentType.JSON));
+
+        }
+        assertFalse(builder.get().hasFailures());
+        client().admin().indices().prepareRefresh("test").get();
+
+        for (int i = minPrecision; i <= maxPrecision; i++) {
+            GeoGridAggregationBuilder builderPoint = aggBuilder.apply("geometry").field("geometry").precision(i);
+            SearchResponse response = client().prepareSearch("test").addAggregation(builderPoint).setSize(0).get();
+            InternalGeoGrid<?> gridPoint = response.getAggregations().get("geometry");
+            assertQuery(gridPoint.getBuckets(), queryBuilder);
+        }
+
+        builder = client().prepareBulk();
+        final int numDocs = randomIntBetween(10, 20);
+        for (int id = 0; id < numDocs; id++) {
+            String wkt = WellKnownText.toWKT(randomGeometriesSupplier.get());
+            String doc = "{\"geometry\" : \"" + wkt + "\"}";
+            builder.add(new IndexRequest("test").source(doc, XContentType.JSON));
+        }
+        assertFalse(builder.get().hasFailures());
+        client().admin().indices().prepareRefresh("test").get();
+
+        int zoom = randomIntBetween(minPrecision, maxPrecision);
+        Rectangle rectangle = toBoundingBox.apply(tiles[zoom]);
+        GeoBoundingBox boundingBox = new GeoBoundingBox(
+            new GeoPoint(rectangle.getMaxLat(), rectangle.getMinLon()),
+            new GeoPoint(rectangle.getMinLat(), rectangle.getMaxLon())
+        );
+
+        for (int i = minPrecision; i <= Math.min(maxPrecision, zoom + 3); i++) {
+            GeoGridAggregationBuilder builderPoint = aggBuilder.apply("geometry")
+                .field("geometry")
+                .precision(i)
+                .setGeoBoundingBox(boundingBox)
+                .size(256 * 256);
+            SearchResponse response = client().prepareSearch("test").addAggregation(builderPoint).setSize(0).get();
+            InternalGeoGrid<?> gridPoint = response.getAggregations().get("geometry");
+            assertQuery(gridPoint.getBuckets(), queryBuilder);
+        }
+    }
+
+    private void assertQuery(List<InternalGeoGridBucket> buckets, BiFunction<String, String, QueryBuilder> queryFunction) {
+        for (InternalGeoGridBucket bucket : buckets) {
+            assertThat(bucket.getDocCount(), Matchers.greaterThan(0L));
+            QueryBuilder queryBuilder = queryFunction.apply("geometry", bucket.getKeyAsString());
+            SearchResponse response = client().prepareSearch("test").setTrackTotalHits(true).setQuery(queryBuilder).get();
+            assertThat(response.getHits().getTotalHits().value, Matchers.equalTo(bucket.getDocCount()));
+        }
+    }
+
+    private static List<Point> toPoints(Rectangle rectangle) {
+        List<Point> points = new ArrayList<>();
+        points.add(new Point(rectangle.getMinX(), rectangle.getMinY()));
+        points.add(new Point(rectangle.getMaxX(), rectangle.getMinY()));
+        points.add(new Point(rectangle.getMinX(), rectangle.getMaxY()));
+        points.add(new Point(rectangle.getMaxX(), rectangle.getMaxY()));
+        return points;
+    }
+}

+ 5 - 3
x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/SpatialPlugin.java

@@ -45,6 +45,7 @@ import org.elasticsearch.xpack.spatial.action.SpatialUsageTransportAction;
 import org.elasticsearch.xpack.spatial.index.mapper.GeoShapeWithDocValuesFieldMapper;
 import org.elasticsearch.xpack.spatial.index.mapper.PointFieldMapper;
 import org.elasticsearch.xpack.spatial.index.mapper.ShapeFieldMapper;
+import org.elasticsearch.xpack.spatial.index.query.GeoGridQueryBuilder;
 import org.elasticsearch.xpack.spatial.index.query.ShapeQueryBuilder;
 import org.elasticsearch.xpack.spatial.ingest.CircleProcessor;
 import org.elasticsearch.xpack.spatial.search.aggregations.GeoLineAggregationBuilder;
@@ -72,8 +73,6 @@ import java.util.List;
 import java.util.Map;
 import java.util.function.Consumer;
 
-import static java.util.Collections.singletonList;
-
 public class SpatialPlugin extends Plugin implements ActionPlugin, MapperPlugin, SearchPlugin, IngestPlugin, ExtensiblePlugin {
     private final SpatialUsage usage = new SpatialUsage();
 
@@ -130,7 +129,10 @@ public class SpatialPlugin extends Plugin implements ActionPlugin, MapperPlugin,
 
     @Override
     public List<QuerySpec<?>> getQueries() {
-        return singletonList(new QuerySpec<>(ShapeQueryBuilder.NAME, ShapeQueryBuilder::new, ShapeQueryBuilder::fromXContent));
+        return List.of(
+            new QuerySpec<>(ShapeQueryBuilder.NAME, ShapeQueryBuilder::new, ShapeQueryBuilder::fromXContent),
+            new QuerySpec<>(GeoGridQueryBuilder.NAME, GeoGridQueryBuilder::new, GeoGridQueryBuilder::fromXContent)
+        );
     }
 
     @Override

+ 5 - 6
x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/index/fielddata/GeoShapeValues.java

@@ -15,7 +15,6 @@ import org.elasticsearch.common.geo.Orientation;
 import org.elasticsearch.common.geo.ShapeRelation;
 import org.elasticsearch.geometry.Geometry;
 import org.elasticsearch.geometry.Point;
-import org.elasticsearch.geometry.Rectangle;
 import org.elasticsearch.geometry.utils.GeographyValidator;
 import org.elasticsearch.geometry.utils.WellKnownText;
 import org.elasticsearch.index.mapper.GeoShapeIndexer;
@@ -127,11 +126,11 @@ public abstract class GeoShapeValues {
             return visitor.labelPosition();
         }
 
-        public GeoRelation relate(Rectangle rectangle) throws IOException {
-            int minX = CoordinateEncoder.GEO.encodeX(rectangle.getMinX());
-            int maxX = CoordinateEncoder.GEO.encodeX(rectangle.getMaxX());
-            int minY = CoordinateEncoder.GEO.encodeY(rectangle.getMinY());
-            int maxY = CoordinateEncoder.GEO.encodeY(rectangle.getMaxY());
+        /**
+         * Determine the {@link GeoRelation} between the current shape and a bounding box provided in
+         * the encoded space.
+         */
+        public GeoRelation relate(int minX, int maxX, int minY, int maxY) throws IOException {
             tile2DVisitor.reset(minX, minY, maxX, maxY);
             reader.visit(tile2DVisitor);
             return tile2DVisitor.relation();

+ 6 - 10
x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/index/fielddata/Tile2DVisitor.java

@@ -13,9 +13,7 @@ import static org.apache.lucene.geo.GeoUtils.orient;
  * A reusable tree reader visitor for a previous serialized {@link org.elasticsearch.geometry.Geometry} using
  * {@link TriangleTreeWriter}.
  *
- * This class supports checking bounding box relations against a serialized triangle tree. Note that this differ
- * from Rectangle2D lucene implementation because it excludes north and east boundary intersections with tiles
- * from intersection consideration for consistent tiling definition of shapes on the boundaries of tiles
+ * This class supports checking bounding box relations against a serialized triangle tree.
  *
  */
 class Tile2DVisitor implements TriangleTreeReader.Visitor {
@@ -91,9 +89,7 @@ class Tile2DVisitor implements TriangleTreeReader.Visitor {
     @Override
     @SuppressWarnings("HiddenField")
     public boolean push(int minX, int minY, int maxX, int maxY) {
-        // exclude north and east boundary intersections with tiles from intersection consideration
-        // for consistent tiling definition of shapes on the boundaries of tiles
-        if (minX >= this.maxX || maxX < this.minX || minY > this.maxY || maxY <= this.minY) {
+        if (minX > this.maxX || maxX < this.minX || minY > this.maxY || maxY < this.minY) {
             // shapes are disjoint
             relation = GeoRelation.QUERY_DISJOINT;
             return false;
@@ -110,7 +106,7 @@ class Tile2DVisitor implements TriangleTreeReader.Visitor {
      * Checks if the rectangle contains the provided point
      **/
     public boolean contains(int x, int y) {
-        return (x <= minX || x > maxX || y < minY || y >= maxY) == false;
+        return x >= minX && x <= maxX && y >= minY && y <= maxY;
     }
 
     /**
@@ -129,7 +125,7 @@ class Tile2DVisitor implements TriangleTreeReader.Visitor {
         int tMaxY = StrictMath.max(aY, bY);
 
         // 2. check bounding boxes are disjoint
-        if (tMaxX <= minX || tMinX > maxX || tMinY > maxY || tMaxY <= minY) {
+        if (tMaxX < minX || tMinX > maxX || tMinY > maxY || tMaxY < minY) {
             return false;
         }
 
@@ -150,8 +146,8 @@ class Tile2DVisitor implements TriangleTreeReader.Visitor {
         int tMinY = StrictMath.min(StrictMath.min(aY, bY), cY);
         int tMaxY = StrictMath.max(StrictMath.max(aY, bY), cY);
 
-        // 1. check bounding boxes are disjoint, where north and east boundaries are not considered as crossing
-        if (tMaxX <= minX || tMinX > maxX || tMinY > maxY || tMaxY <= minY) {
+        // 1. check bounding boxes are disjoint
+        if (tMaxX < minX || tMinX > maxX || tMinY > maxY || tMaxY < minY) {
             return GeoRelation.QUERY_DISJOINT;
         }
 

+ 391 - 0
x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/index/query/GeoGridQueryBuilder.java

@@ -0,0 +1,391 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.spatial.index.query;
+
+import org.apache.lucene.geo.GeoEncodingUtils;
+import org.apache.lucene.search.MatchNoDocsQuery;
+import org.apache.lucene.search.Query;
+import org.elasticsearch.ElasticsearchParseException;
+import org.elasticsearch.Version;
+import org.elasticsearch.common.ParsingException;
+import org.elasticsearch.common.geo.ShapeRelation;
+import org.elasticsearch.common.io.stream.StreamInput;
+import org.elasticsearch.common.io.stream.StreamOutput;
+import org.elasticsearch.geometry.Rectangle;
+import org.elasticsearch.geometry.utils.Geohash;
+import org.elasticsearch.h3.H3;
+import org.elasticsearch.index.mapper.GeoPointFieldMapper;
+import org.elasticsearch.index.mapper.GeoPointScriptFieldType;
+import org.elasticsearch.index.mapper.GeoShapeQueryable;
+import org.elasticsearch.index.mapper.MappedFieldType;
+import org.elasticsearch.index.query.AbstractQueryBuilder;
+import org.elasticsearch.index.query.QueryShardException;
+import org.elasticsearch.index.query.SearchExecutionContext;
+import org.elasticsearch.search.aggregations.bucket.geogrid.GeoTileUtils;
+import org.elasticsearch.xcontent.ParseField;
+import org.elasticsearch.xcontent.XContentBuilder;
+import org.elasticsearch.xcontent.XContentParser;
+
+import java.io.IOException;
+import java.util.Objects;
+
+/**
+ * Creates a Lucene query that will filter for all documents that intersects the specified
+ * bin of a grid.
+ *
+ * It supports geohash and geotile grids for both GeoShape and GeoPoint and the geohex grid
+ * only for GeoPoint.
+ * */
+public class GeoGridQueryBuilder extends AbstractQueryBuilder<GeoGridQueryBuilder> {
+    public static final String NAME = "geo_grid";
+
+    /** Grids supported by this query */
+    public enum Grid {
+        GEOHASH {
+
+            private static final String name = "geohash";
+
+            @Override
+            protected Query toQuery(SearchExecutionContext context, String fieldName, MappedFieldType fieldType, String id) {
+                if (fieldType instanceof GeoShapeQueryable geoShapeQueryable) {
+                    return geoShapeQueryable.geoShapeQuery(context, fieldName, ShapeRelation.INTERSECTS, getQueryHash(id));
+                }
+                throw new QueryShardException(
+                    context,
+                    "Field [" + fieldName + "] is of unsupported type [" + fieldType.typeName() + "] for [" + NAME + "] query"
+                );
+            }
+
+            @Override
+            protected String getName() {
+                return name;
+            }
+
+            @Override
+            protected void validate(String gridId) {
+                Geohash.mortonEncode(gridId);
+            }
+        },
+        GEOTILE {
+
+            private static final String name = "geotile";
+
+            @Override
+            protected Query toQuery(SearchExecutionContext context, String fieldName, MappedFieldType fieldType, String id) {
+                if (fieldType instanceof GeoShapeQueryable geoShapeQueryable) {
+                    return geoShapeQueryable.geoShapeQuery(context, fieldName, ShapeRelation.INTERSECTS, getQueryTile(id));
+                }
+                throw new QueryShardException(
+                    context,
+                    "Field [" + fieldName + "] is of unsupported type [" + fieldType.typeName() + "] for [" + NAME + "] query"
+                );
+            }
+
+            @Override
+            protected String getName() {
+                return name;
+            }
+
+            @Override
+            protected void validate(String gridId) {
+                GeoTileUtils.longEncode(gridId);
+            }
+        },
+
+        GEOHEX {
+            private static final String name = "geohex";
+
+            @Override
+            protected Query toQuery(SearchExecutionContext context, String fieldName, MappedFieldType fieldType, String id) {
+                H3LatLonGeometry geometry = new H3LatLonGeometry(id);
+                if (fieldType instanceof GeoPointFieldMapper.GeoPointFieldType pointFieldType) {
+                    return pointFieldType.geoShapeQuery(context, fieldName, ShapeRelation.INTERSECTS, geometry);
+                } else if (fieldType instanceof GeoPointScriptFieldType scriptType) {
+                    return scriptType.geoShapeQuery(context, fieldName, ShapeRelation.INTERSECTS, geometry);
+                }
+                throw new QueryShardException(
+                    context,
+                    "Field [" + fieldName + "] is of unsupported type [" + fieldType.typeName() + "] for [" + NAME + "] query"
+                );
+            }
+
+            @Override
+            protected String getName() {
+                return name;
+            }
+
+            @Override
+            protected void validate(String gridId) {
+                boolean valid;
+                try {
+                    valid = H3.h3IsValid(gridId);
+                } catch (Exception e) {
+                    throw new IllegalArgumentException("Invalid h3 address [" + gridId + "]", e);
+                }
+                if (valid == false) {
+                    throw new IllegalArgumentException("Invalid h3 address [" + gridId + "]");
+                }
+            }
+        };
+
+        protected abstract Query toQuery(SearchExecutionContext context, String fieldName, MappedFieldType fieldType, String id);
+
+        protected abstract String getName();
+
+        protected abstract void validate(String gridId);
+
+        private static Grid fromName(String name) {
+            if (GEOHEX.getName().equals(name)) {
+                return GEOHEX;
+            } else if (GEOTILE.getName().equals(name)) {
+                return GEOTILE;
+            } else if (GEOHASH.getName().equals(name)) {
+                return GEOHASH;
+            } else {
+                throw new ElasticsearchParseException("failed to parse [{}] query. Invalid grid name [" + name + "]", NAME);
+            }
+        }
+
+    }
+
+    // public for testing
+    public static Rectangle getQueryTile(String id) {
+        final Rectangle rectangle = GeoTileUtils.toBoundingBox(id);
+        final int minY = GeoEncodingUtils.encodeLatitude(rectangle.getMinLat());
+        final int minX = GeoEncodingUtils.encodeLongitude(rectangle.getMinLon());
+        final int maxY = GeoEncodingUtils.encodeLatitude(rectangle.getMaxLat());
+        final int maxX = GeoEncodingUtils.encodeLongitude(rectangle.getMaxLon());
+        return new Rectangle(
+            GeoEncodingUtils.decodeLongitude(minX),
+            GeoEncodingUtils.decodeLongitude(maxX == Integer.MAX_VALUE ? maxX : maxX - 1),
+            GeoEncodingUtils.decodeLatitude(maxY),
+            GeoEncodingUtils.decodeLatitude(minY == GeoTileUtils.ENCODED_NEGATIVE_LATITUDE_MASK ? minY : minY + 1)
+        );
+    }
+
+    // public for testing
+    public static Rectangle getQueryHash(String id) {
+        final Rectangle rectangle = Geohash.toBoundingBox(id);
+        final int minX = GeoEncodingUtils.encodeLongitude(rectangle.getMinLon());
+        final int minY = GeoEncodingUtils.encodeLatitude(rectangle.getMinLat());
+        final int maxX = GeoEncodingUtils.encodeLongitude(rectangle.getMaxLon());
+        final int maxY = GeoEncodingUtils.encodeLatitude(rectangle.getMaxLat());
+        return new Rectangle(
+            GeoEncodingUtils.decodeLongitude(minX),
+            GeoEncodingUtils.decodeLongitude(maxX == Integer.MAX_VALUE ? maxX : maxX - 1),
+            GeoEncodingUtils.decodeLatitude(maxY == Integer.MAX_VALUE ? maxY : maxY - 1),
+            GeoEncodingUtils.decodeLatitude(minY)
+        );
+    }
+
+    /**
+     * The default value for ignore_unmapped.
+     */
+    private static final boolean DEFAULT_IGNORE_UNMAPPED = false;
+    private static final ParseField IGNORE_UNMAPPED_FIELD = new ParseField("ignore_unmapped");
+
+    /** Name of field holding geo coordinates to compute the bounding box on.*/
+    private final String fieldName;
+    private Grid grid;
+    private String gridId;
+    private boolean ignoreUnmapped = DEFAULT_IGNORE_UNMAPPED;
+
+    /**
+     * Create new grid query.
+     * @param fieldName name of index field containing geo coordinates to operate on.
+     * */
+    public GeoGridQueryBuilder(String fieldName) {
+        if (fieldName == null) {
+            throw new IllegalArgumentException("Field name must not be empty.");
+        }
+        this.fieldName = fieldName;
+    }
+
+    /**
+     * Read from a stream.
+     */
+    public GeoGridQueryBuilder(StreamInput in) throws IOException {
+        super(in);
+        fieldName = in.readString();
+        grid = Grid.fromName(in.readString());
+        gridId = in.readString();
+        ignoreUnmapped = in.readBoolean();
+    }
+
+    @Override
+    protected void doWriteTo(StreamOutput out) throws IOException {
+        out.writeString(fieldName);
+        out.writeString(grid.getName());
+        out.writeString(gridId);
+        out.writeBoolean(ignoreUnmapped);
+    }
+
+    /**
+     * Adds the grid and the gridId
+     * @param grid The type of grid
+     * @param gridId The grid bin identifier
+     */
+    public GeoGridQueryBuilder setGridId(Grid grid, String gridId) {
+        grid.validate(gridId);
+        this.grid = grid;
+        this.gridId = gridId;
+        return this;
+    }
+
+    /** Returns the name of the field to base the grid computation on. */
+    public String fieldName() {
+        return this.fieldName;
+    }
+
+    /**
+     * Sets whether the query builder should ignore unmapped fields (and run a
+     * {@link MatchNoDocsQuery} in place of this query) or throw an exception if
+     * the field is unmapped.
+     */
+    public GeoGridQueryBuilder ignoreUnmapped(boolean ignoreUnmapped) {
+        this.ignoreUnmapped = ignoreUnmapped;
+        return this;
+    }
+
+    /**
+     * Gets whether the query builder will ignore unmapped fields (and run a
+     * {@link MatchNoDocsQuery} in place of this query) or throw an exception if
+     * the field is unmapped.
+     */
+    public boolean ignoreUnmapped() {
+        return ignoreUnmapped;
+    }
+
+    @Override
+    public Query doToQuery(SearchExecutionContext context) {
+        MappedFieldType fieldType = context.getFieldType(fieldName);
+        if (fieldType == null) {
+            if (ignoreUnmapped) {
+                return new MatchNoDocsQuery();
+            } else {
+                throw new QueryShardException(context, "failed to find geo field [" + fieldName + "]");
+            }
+        }
+        return grid.toQuery(context, fieldName, fieldType, gridId);
+    }
+
+    @Override
+    protected void doXContent(XContentBuilder builder, Params params) throws IOException {
+        builder.startObject(NAME);
+
+        builder.startObject(fieldName);
+        builder.field(grid.getName(), gridId);
+        builder.endObject();
+        builder.field(IGNORE_UNMAPPED_FIELD.getPreferredName(), ignoreUnmapped);
+
+        boostAndQueryNameToXContent(builder);
+
+        builder.endObject();
+    }
+
+    public static GeoGridQueryBuilder fromXContent(XContentParser parser) throws IOException {
+        String fieldName = null;
+
+        float boost = AbstractQueryBuilder.DEFAULT_BOOST;
+        String queryName = null;
+        String currentFieldName = null;
+        XContentParser.Token token;
+        boolean ignoreUnmapped = DEFAULT_IGNORE_UNMAPPED;
+        Grid grid = null;
+        String gridId = null;
+
+        while ((token = parser.nextToken()) != XContentParser.Token.END_OBJECT) {
+            if (token == XContentParser.Token.FIELD_NAME) {
+                currentFieldName = parser.currentName();
+            } else if (token == XContentParser.Token.START_OBJECT) {
+                fieldName = currentFieldName;
+                while ((token = parser.nextToken()) != XContentParser.Token.END_OBJECT) {
+                    if (token == XContentParser.Token.FIELD_NAME) {
+                        if (grid != null) {
+                            throw new ParsingException(
+                                parser.getTokenLocation(),
+                                "failed to parse [{}] query. unexpected field [{}]",
+                                NAME,
+                                parser.currentName()
+                            );
+                        }
+                        grid = Grid.fromName(parser.currentName());
+                        if (parser.nextToken().isValue()) {
+                            gridId = parser.text();
+                        } else {
+                            throw new ParsingException(
+                                parser.getTokenLocation(),
+                                "failed to parse [{}] query. unexpected field [{}]",
+                                NAME,
+                                parser.currentName()
+                            );
+                        }
+                    } else {
+                        throw new ParsingException(
+                            parser.getTokenLocation(),
+                            "failed to parse [{}] query. unexpected field [{}]",
+                            NAME,
+                            currentFieldName
+                        );
+                    }
+                }
+            } else if (token.isValue()) {
+                if (AbstractQueryBuilder.NAME_FIELD.match(currentFieldName, parser.getDeprecationHandler())) {
+                    queryName = parser.text();
+                } else if (AbstractQueryBuilder.BOOST_FIELD.match(currentFieldName, parser.getDeprecationHandler())) {
+                    boost = parser.floatValue();
+                } else if (IGNORE_UNMAPPED_FIELD.match(currentFieldName, parser.getDeprecationHandler())) {
+                    ignoreUnmapped = parser.booleanValue();
+                } else {
+                    throw new ParsingException(
+                        parser.getTokenLocation(),
+                        "failed to parse [{}] query. unexpected field [{}]",
+                        NAME,
+                        currentFieldName
+                    );
+                }
+            }
+        }
+
+        if (grid == null) {
+            throw new ElasticsearchParseException("failed to parse [{}] query. grid name not provided", NAME);
+        }
+        if (gridId == null) {
+            throw new ElasticsearchParseException("failed to parse [{}] query. grid id not provided", NAME);
+        }
+        GeoGridQueryBuilder builder = new GeoGridQueryBuilder(fieldName);
+        builder.setGridId(grid, gridId);
+        builder.queryName(queryName);
+        builder.boost(boost);
+        builder.ignoreUnmapped(ignoreUnmapped);
+        return builder;
+    }
+
+    @Override
+    protected boolean doEquals(GeoGridQueryBuilder other) {
+        return Objects.equals(grid, other.grid)
+            && Objects.equals(gridId, other.gridId)
+            && Objects.equals(fieldName, other.fieldName)
+            && Objects.equals(ignoreUnmapped, other.ignoreUnmapped);
+    }
+
+    @Override
+    protected int doHashCode() {
+        return Objects.hash(grid, gridId, fieldName, ignoreUnmapped);
+    }
+
+    @Override
+    public String getWriteableName() {
+        return NAME;
+    }
+
+    @Override
+    public Version getMinimalSupportedVersion() {
+        return Version.V_8_3_0;
+    }
+}

+ 215 - 0
x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/index/query/H3LatLonGeometry.java

@@ -0,0 +1,215 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.spatial.index.query;
+
+import org.apache.lucene.geo.Component2D;
+import org.apache.lucene.geo.GeoUtils;
+import org.apache.lucene.geo.LatLonGeometry;
+import org.apache.lucene.index.PointValues;
+import org.apache.lucene.spatial3d.geom.GeoArea;
+import org.apache.lucene.spatial3d.geom.GeoAreaFactory;
+import org.apache.lucene.spatial3d.geom.GeoPoint;
+import org.apache.lucene.spatial3d.geom.GeoPolygon;
+import org.apache.lucene.spatial3d.geom.GeoPolygonFactory;
+import org.apache.lucene.spatial3d.geom.PlanetModel;
+import org.elasticsearch.h3.CellBoundary;
+import org.elasticsearch.h3.H3;
+import org.elasticsearch.h3.LatLng;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+
+/** Implementation of a lucene {@link LatLonGeometry} that covers the extent of a provided H3 bin. Note that
+ * H3 bin are polygons on the sphere. */
+class H3LatLonGeometry extends LatLonGeometry {
+
+    private final String h3Address;
+
+    H3LatLonGeometry(String h3Address) {
+        this.h3Address = h3Address;
+    }
+
+    @Override
+    protected Component2D toComponent2D() {
+        return new H3Polygon2D(h3Address);
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (o instanceof H3LatLonGeometry h3) {
+            return Objects.equals(h3Address, h3.h3Address);
+        }
+        return false;
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hashCode(h3Address);
+    }
+
+    @Override
+    public String toString() {
+        StringBuilder sb = new StringBuilder();
+        sb.append("H3 : ");
+        sb.append("\"");
+        sb.append(h3Address);
+        sb.append("\"");
+        return sb.toString();
+    }
+
+    private static class H3Polygon2D implements Component2D {
+
+        private final long h3;
+        private final int res;
+        private final GeoPolygon hexagon;
+
+        private H3Polygon2D(String h3Address) {
+            h3 = H3.stringToH3(h3Address);
+            res = H3.getResolution(h3Address);
+            hexagon = getGeoPolygon(h3Address);
+            // I tried to compute the bounding box to set min/max values, but it seems to fail
+            // due to numerical errors. For now, we just don't use it, this means we will not be
+            // using the optimization provided by lucene's ComponentPredicate.
+
+        }
+
+        private GeoPolygon getGeoPolygon(String h3Address) {
+            final CellBoundary cellBoundary = H3.h3ToGeoBoundary(h3Address);
+            final List<GeoPoint> points = new ArrayList<>(cellBoundary.numPoints());
+            for (int i = 0; i < cellBoundary.numPoints(); i++) {
+                final LatLng latLng = cellBoundary.getLatLon(i);
+                points.add(new GeoPoint(PlanetModel.SPHERE, latLng.getLatRad(), latLng.getLonRad()));
+            }
+            return GeoPolygonFactory.makeGeoPolygon(PlanetModel.SPHERE, points);
+        }
+
+        @Override
+        public double getMinX() {
+            return GeoUtils.MIN_LON_INCL;
+        }
+
+        @Override
+        public double getMaxX() {
+            return GeoUtils.MAX_LON_INCL;
+        }
+
+        @Override
+        public double getMinY() {
+            return GeoUtils.MIN_LAT_INCL;
+        }
+
+        @Override
+        public double getMaxY() {
+            return GeoUtils.MAX_LAT_INCL;
+        }
+
+        @Override
+        public boolean contains(double x, double y) {
+            return h3 == H3.geoToH3(y, x, res);
+        }
+
+        @Override
+        public PointValues.Relation relate(double minX, double maxX, double minY, double maxY) {
+            GeoArea box = GeoAreaFactory.makeGeoArea(
+                PlanetModel.SPHERE,
+                Math.toRadians(maxY),
+                Math.toRadians(minY),
+                Math.toRadians(minX),
+                Math.toRadians(maxX)
+            );
+            return switch (box.getRelationship(hexagon)) {
+                case GeoArea.CONTAINS -> PointValues.Relation.CELL_INSIDE_QUERY;
+                case GeoArea.DISJOINT -> PointValues.Relation.CELL_OUTSIDE_QUERY;
+                default -> PointValues.Relation.CELL_CROSSES_QUERY;
+            };
+        }
+
+        @Override
+        public boolean intersectsLine(double minX, double maxX, double minY, double maxY, double aX, double aY, double bX, double bY) {
+            throw new UnsupportedOperationException("intersectsLine not implemented in H3Polygon2D");
+        }
+
+        @Override
+        public boolean intersectsTriangle(
+            double minX,
+            double maxX,
+            double minY,
+            double maxY,
+            double aX,
+            double aY,
+            double bX,
+            double bY,
+            double cX,
+            double cY
+        ) {
+            throw new UnsupportedOperationException("intersectsTriangle not implemented in H3Polygon2D");
+        }
+
+        @Override
+        public boolean containsLine(double minX, double maxX, double minY, double maxY, double aX, double aY, double bX, double bY) {
+            throw new UnsupportedOperationException("containsLine not implemented in H3Polygon2D");
+        }
+
+        @Override
+        public boolean containsTriangle(
+            double minX,
+            double maxX,
+            double minY,
+            double maxY,
+            double aX,
+            double aY,
+            double bX,
+            double bY,
+            double cX,
+            double cY
+        ) {
+            throw new IllegalArgumentException();
+        }
+
+        @Override
+        public WithinRelation withinPoint(double x, double y) {
+            return contains(x, y) ? WithinRelation.NOTWITHIN : WithinRelation.DISJOINT;
+        }
+
+        @Override
+        public WithinRelation withinLine(
+            double minX,
+            double maxX,
+            double minY,
+            double maxY,
+            double aX,
+            double aY,
+            boolean ab,
+            double bX,
+            double bY
+        ) {
+            throw new UnsupportedOperationException("withinLine not implemented in H3Polygon2D");
+        }
+
+        @Override
+        public WithinRelation withinTriangle(
+            double minX,
+            double maxX,
+            double minY,
+            double maxY,
+            double aX,
+            double aY,
+            boolean ab,
+            double bX,
+            double bY,
+            boolean bc,
+            double cX,
+            double cY,
+            boolean ca
+        ) {
+            throw new UnsupportedOperationException("withinTriangle not implemented in H3Polygon2D");
+        }
+    }
+}

+ 11 - 1
x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/AbstractGeoHashGridTiler.java

@@ -7,6 +7,8 @@
 
 package org.elasticsearch.xpack.spatial.search.aggregations.bucket.geogrid;
 
+import org.apache.lucene.geo.GeoEncodingUtils;
+import org.elasticsearch.geometry.Rectangle;
 import org.elasticsearch.geometry.utils.Geohash;
 import org.elasticsearch.xpack.spatial.index.fielddata.GeoRelation;
 import org.elasticsearch.xpack.spatial.index.fielddata.GeoShapeValues;
@@ -92,7 +94,15 @@ abstract class AbstractGeoHashGridTiler extends GeoGridTiler {
     }
 
     private GeoRelation relateTile(GeoShapeValues.GeoShapeValue geoValue, String hash) throws IOException {
-        return validHash(hash) ? geoValue.relate(Geohash.toBoundingBox(hash)) : GeoRelation.QUERY_DISJOINT;
+        if (validHash(hash)) {
+            final Rectangle rectangle = Geohash.toBoundingBox(hash);
+            int minX = GeoEncodingUtils.encodeLongitude(rectangle.getMinLon());
+            int minY = GeoEncodingUtils.encodeLatitude(rectangle.getMinLat());
+            int maxX = GeoEncodingUtils.encodeLongitude(rectangle.getMaxLon());
+            int maxY = GeoEncodingUtils.encodeLatitude(rectangle.getMaxLat());
+            return geoValue.relate(minX, maxX == Integer.MAX_VALUE ? maxX : maxX - 1, minY, maxY == Integer.MAX_VALUE ? maxY : maxY - 1);
+        }
+        return GeoRelation.QUERY_DISJOINT;
     }
 
     protected int setValuesByRasterization(String hash, GeoShapeCellValues values, int valuesIndex, GeoShapeValues.GeoShapeValue geoValue)

+ 15 - 3
x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/AbstractGeoTileGridTiler.java

@@ -7,6 +7,7 @@
 
 package org.elasticsearch.xpack.spatial.search.aggregations.bucket.geogrid;
 
+import org.apache.lucene.geo.GeoEncodingUtils;
 import org.elasticsearch.search.aggregations.bucket.geogrid.GeoTileUtils;
 import org.elasticsearch.xpack.spatial.index.fielddata.GeoRelation;
 import org.elasticsearch.xpack.spatial.index.fielddata.GeoShapeValues;
@@ -75,9 +76,20 @@ abstract class AbstractGeoTileGridTiler extends GeoGridTiler {
     }
 
     private GeoRelation relateTile(GeoShapeValues.GeoShapeValue geoValue, int xTile, int yTile, int precision) throws IOException {
-        return validTile(xTile, yTile, precision)
-            ? geoValue.relate(GeoTileUtils.toBoundingBox(xTile, yTile, precision))
-            : GeoRelation.QUERY_DISJOINT;
+        if (validTile(xTile, yTile, precision)) {
+            final double tiles = 1 << precision;
+            final int minX = GeoEncodingUtils.encodeLongitude(GeoTileUtils.tileToLon(xTile, tiles));
+            final int maxX = GeoEncodingUtils.encodeLongitude(GeoTileUtils.tileToLon(xTile + 1, tiles));
+            final int minY = GeoEncodingUtils.encodeLatitude(GeoTileUtils.tileToLat(yTile + 1, tiles));
+            final int maxY = GeoEncodingUtils.encodeLatitude(GeoTileUtils.tileToLat(yTile, tiles));
+            return geoValue.relate(
+                minX,
+                maxX == Integer.MAX_VALUE ? maxX : maxX - 1,
+                minY == GeoTileUtils.ENCODED_NEGATIVE_LATITUDE_MASK ? minY : minY + 1,
+                maxY
+            );
+        }
+        return GeoRelation.QUERY_DISJOINT;
     }
 
     /**

+ 284 - 0
x-pack/plugin/spatial/src/test/java/org/elasticsearch/xpack/spatial/index/query/GeoGridQueryBuilderTests.java

@@ -0,0 +1,284 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.spatial.index.query;
+
+import org.apache.lucene.geo.GeoEncodingUtils;
+import org.apache.lucene.search.IndexOrDocValuesQuery;
+import org.apache.lucene.search.MatchNoDocsQuery;
+import org.apache.lucene.search.Query;
+import org.elasticsearch.ElasticsearchParseException;
+import org.elasticsearch.common.ParsingException;
+import org.elasticsearch.geo.GeometryTestUtils;
+import org.elasticsearch.geometry.Rectangle;
+import org.elasticsearch.geometry.utils.Geohash;
+import org.elasticsearch.h3.H3;
+import org.elasticsearch.index.mapper.MappedFieldType;
+import org.elasticsearch.index.query.QueryShardException;
+import org.elasticsearch.index.query.SearchExecutionContext;
+import org.elasticsearch.plugins.Plugin;
+import org.elasticsearch.search.aggregations.bucket.geogrid.GeoTileUtils;
+import org.elasticsearch.test.AbstractQueryTestCase;
+import org.elasticsearch.xpack.spatial.LocalStateSpatialPlugin;
+
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.Collection;
+
+import static org.elasticsearch.search.aggregations.bucket.geogrid.GeoTileUtils.MAX_ZOOM;
+import static org.elasticsearch.search.aggregations.bucket.geogrid.GeoTileUtils.longEncode;
+import static org.elasticsearch.search.aggregations.bucket.geogrid.GeoTileUtils.stringEncode;
+import static org.hamcrest.CoreMatchers.containsString;
+import static org.hamcrest.CoreMatchers.instanceOf;
+import static org.hamcrest.CoreMatchers.notNullValue;
+
+public class GeoGridQueryBuilderTests extends AbstractQueryTestCase<GeoGridQueryBuilder> {
+
+    @Override
+    protected Collection<Class<? extends Plugin>> getPlugins() {
+        return Arrays.asList(LocalStateSpatialPlugin.class);
+    }
+
+    @Override
+    protected GeoGridQueryBuilder doCreateTestQueryBuilder() {
+        String fieldName = randomFrom(GEO_POINT_FIELD_NAME, GEO_POINT_ALIAS_FIELD_NAME, GEO_SHAPE_FIELD_NAME);
+        GeoGridQueryBuilder builder = new GeoGridQueryBuilder(fieldName);
+
+        // Only use geohex for points
+        int path = randomIntBetween(0, GEO_SHAPE_FIELD_NAME.equals(fieldName) ? 1 : 2);
+        switch (path) {
+            case 0 -> builder.setGridId(GeoGridQueryBuilder.Grid.GEOHASH, randomGeohash());
+            case 1 -> builder.setGridId(GeoGridQueryBuilder.Grid.GEOTILE, randomGeotile());
+            default -> builder.setGridId(GeoGridQueryBuilder.Grid.GEOHEX, randomGeohex());
+        }
+
+        if (randomBoolean()) {
+            builder.ignoreUnmapped(randomBoolean());
+        }
+
+        return builder;
+    }
+
+    private String randomGeohash() {
+        return Geohash.stringEncode(GeometryTestUtils.randomLon(), GeometryTestUtils.randomLat(), randomIntBetween(1, Geohash.PRECISION));
+    }
+
+    private String randomGeotile() {
+        final long encoded = GeoTileUtils.longEncode(
+            GeometryTestUtils.randomLon(),
+            GeometryTestUtils.randomLat(),
+            randomIntBetween(0, GeoTileUtils.MAX_ZOOM)
+        );
+        return GeoTileUtils.stringEncode(encoded);
+    }
+
+    private String randomGeohex() {
+        return H3.geoToH3Address(GeometryTestUtils.randomLat(), GeometryTestUtils.randomLon(), randomIntBetween(0, H3.MAX_H3_RES));
+    }
+
+    public void testValidationNullFieldname() {
+        IllegalArgumentException e = expectThrows(IllegalArgumentException.class, () -> new GeoGridQueryBuilder((String) null));
+        assertEquals("Field name must not be empty.", e.getMessage());
+    }
+
+    public void testExceptionOnMissingTypes() {
+        SearchExecutionContext context = createShardContextWithNoType();
+        GeoGridQueryBuilder qb = createTestQueryBuilder();
+        qb.ignoreUnmapped(false);
+        QueryShardException e = expectThrows(QueryShardException.class, () -> qb.toQuery(context));
+        assertEquals("failed to find geo field [" + qb.fieldName() + "]", e.getMessage());
+    }
+
+    @Override
+    protected void doAssertLuceneQuery(GeoGridQueryBuilder queryBuilder, Query query, SearchExecutionContext context) {
+        final MappedFieldType fieldType = context.getFieldType(queryBuilder.fieldName());
+        if (fieldType == null) {
+            assertTrue("Found no indexed geo query.", query instanceof MatchNoDocsQuery);
+        } else if (fieldType.hasDocValues()) {
+            assertEquals(IndexOrDocValuesQuery.class, query.getClass());
+        }
+    }
+
+    public void testParsingAndToQueryGeohex() throws IOException {
+        String query = """
+            {
+                "geo_grid":{
+                    "%s":{
+                        "geohex": "%s"
+                    }
+                }
+            }
+            """.formatted(GEO_POINT_FIELD_NAME, randomGeohex());
+        assertGeoGridQuery(query);
+    }
+
+    public void testParsingAndToQueryGeotile() throws IOException {
+        String query = """
+            {
+                "geo_grid":{
+                    "%s":{
+                        "geotile": "%s"
+                    }
+                }
+            }
+            """.formatted(GEO_POINT_FIELD_NAME, randomGeotile());
+        assertGeoGridQuery(query);
+    }
+
+    public void testParsingAndToQueryGeohash() throws IOException {
+        String query = """
+            {
+                "geo_grid":{
+                    "%s":{
+                        "geohash": "%s"
+                    }
+                }
+            }
+            """.formatted(GEO_POINT_FIELD_NAME, randomGeohash());
+        assertGeoGridQuery(query);
+    }
+
+    private void assertGeoGridQuery(String query) throws IOException {
+        SearchExecutionContext searchExecutionContext = createSearchExecutionContext();
+        // just check if we can parse the query
+        parseQuery(query).toQuery(searchExecutionContext);
+    }
+
+    public void testMalformedGeoTile() {
+        String query = """
+            {
+                "geo_grid":{
+                    "%s":{
+                        "geotile": "%s"
+                    }
+                }
+            }
+            """.formatted(GEO_POINT_FIELD_NAME, randomGeohex());
+        IllegalArgumentException e1 = expectThrows(IllegalArgumentException.class, () -> parseQuery(query));
+        assertThat(e1.getMessage(), containsString("Invalid geotile_grid hash string of"));
+    }
+
+    public void testMalformedGeohash() {
+        String query = """
+            {
+                "geo_grid":{
+                    "%s":{
+                        "geohash": "%s"
+                    }
+                }
+            }
+            """.formatted(GEO_POINT_FIELD_NAME, randomGeotile());
+        IllegalArgumentException e1 = expectThrows(IllegalArgumentException.class, () -> parseQuery(query));
+        assertThat(e1.getMessage(), containsString("unsupported symbol [/] in geohash"));
+    }
+
+    public void testMalformedGeohex() {
+        String query = """
+            {
+                "geo_grid":{
+                    "%s":{
+                        "geohex": "%s"
+                    }
+                }
+            }
+            """.formatted(GEO_POINT_FIELD_NAME, randomGeotile());
+        IllegalArgumentException e1 = expectThrows(IllegalArgumentException.class, () -> parseQuery(query));
+        assertThat(e1.getMessage(), containsString("Invalid h3 address"));
+    }
+
+    public void testWrongField() {
+        String query = """
+            {
+                "geo_grid":{
+                    "%s":{
+                        "geohexes": "%s"
+                    }
+                }
+            }
+            """.formatted(GEO_POINT_FIELD_NAME, randomGeohex());
+        ElasticsearchParseException e1 = expectThrows(ElasticsearchParseException.class, () -> parseQuery(query));
+        assertThat(e1.getMessage(), containsString("Invalid grid name [geohexes]"));
+    }
+
+    public void testDuplicateField() {
+        String query = """
+            {
+                "geo_grid":{
+                    "%s":{
+                        "geohex": "%s",
+                        "geotile": "%s"
+                    }
+                }
+            }
+            """.formatted(GEO_POINT_FIELD_NAME, randomGeohex(), randomGeotile());
+        ParsingException e1 = expectThrows(ParsingException.class, () -> parseQuery(query));
+        assertThat(e1.getMessage(), containsString("failed to parse [geo_grid] query. unexpected field [geotile]"));
+    }
+
+    public void testIgnoreUnmapped() throws IOException {
+        final GeoGridQueryBuilder queryBuilder = new GeoGridQueryBuilder("unmapped").setGridId(GeoGridQueryBuilder.Grid.GEOTILE, "0/0/0");
+        queryBuilder.ignoreUnmapped(true);
+        SearchExecutionContext searchExecutionContext = createSearchExecutionContext();
+        Query query = queryBuilder.toQuery(searchExecutionContext);
+        assertThat(query, notNullValue());
+        assertThat(query, instanceOf(MatchNoDocsQuery.class));
+
+        final GeoGridQueryBuilder failingQueryBuilder = new GeoGridQueryBuilder("unmapped").setGridId(
+            GeoGridQueryBuilder.Grid.GEOTILE,
+            "0/0/0"
+        );
+        failingQueryBuilder.ignoreUnmapped(false);
+        QueryShardException e = expectThrows(QueryShardException.class, () -> failingQueryBuilder.toQuery(searchExecutionContext));
+        assertThat(e.getMessage(), containsString("failed to find geo field [unmapped]"));
+    }
+
+    public void testGeohashBoundingBox() {
+        double lat = randomDoubleBetween(-90d, 90d, true);
+        double lon = randomDoubleBetween(-180d, 180d, true);
+        double qLat = GeoEncodingUtils.decodeLatitude(GeoEncodingUtils.encodeLatitude(lat));
+        double qLon = GeoEncodingUtils.decodeLongitude(GeoEncodingUtils.encodeLongitude(lon));
+        for (int zoom = 1; zoom <= Geohash.PRECISION; zoom++) {
+            String hash = Geohash.stringEncode(qLon, qLat, zoom);
+            Rectangle qRect = GeoGridQueryBuilder.getQueryHash(hash);
+            assertBoundingBox(hash, zoom, qLon, qLat, qRect);
+            assertBoundingBox(hash, zoom, qRect.getMinX(), qRect.getMinY(), qRect);
+            assertBoundingBox(hash, zoom, qRect.getMaxX(), qRect.getMinY(), qRect);
+            assertBoundingBox(hash, zoom, qRect.getMinX(), qRect.getMaxY(), qRect);
+            assertBoundingBox(hash, zoom, qRect.getMaxX(), qRect.getMaxY(), qRect);
+        }
+    }
+
+    private void assertBoundingBox(String hash, int precision, double lon, double lat, Rectangle r) {
+        assertEquals(
+            Geohash.stringEncode(lon, lat, precision).equals(hash),
+            org.apache.lucene.geo.Rectangle.containsPoint(lat, lon, r.getMinLat(), r.getMaxLat(), r.getMinLon(), r.getMaxLon())
+        );
+    }
+
+    public void testBoundingBoxQuantize() {
+        double lat = randomDoubleBetween(-GeoTileUtils.LATITUDE_MASK, GeoTileUtils.LATITUDE_MASK, true);
+        double lon = randomDoubleBetween(-180d, 180d, true);
+        double qLat = GeoEncodingUtils.decodeLatitude(GeoEncodingUtils.encodeLatitude(lat));
+        double qLon = GeoEncodingUtils.decodeLongitude(GeoEncodingUtils.encodeLongitude(lon));
+        for (int zoom = 0; zoom <= MAX_ZOOM; zoom++) {
+            long tile = GeoTileUtils.longEncode(qLon, qLat, zoom);
+            Rectangle qRect = GeoGridQueryBuilder.getQueryTile(stringEncode(tile));
+            assertBoundingBox(tile, zoom, qLon, qLat, qRect);
+            assertBoundingBox(tile, zoom, qRect.getMinX(), qRect.getMinY(), qRect);
+            assertBoundingBox(tile, zoom, qRect.getMaxX(), qRect.getMinY(), qRect);
+            assertBoundingBox(tile, zoom, qRect.getMinX(), qRect.getMaxY(), qRect);
+            assertBoundingBox(tile, zoom, qRect.getMaxX(), qRect.getMaxY(), qRect);
+        }
+    }
+
+    private void assertBoundingBox(long tile, int zoom, double lon, double lat, Rectangle r) {
+        assertEquals(
+            longEncode(lon, lat, zoom) == tile,
+            org.apache.lucene.geo.Rectangle.containsPoint(lat, lon, r.getMinLat(), r.getMaxLat(), r.getMinLon(), r.getMaxLon())
+        );
+    }
+}

+ 93 - 0
x-pack/plugin/spatial/src/test/java/org/elasticsearch/xpack/spatial/index/query/H3LatLonGeometryTests.java

@@ -0,0 +1,93 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.spatial.index.query;
+
+import org.apache.lucene.document.Document;
+import org.apache.lucene.document.LatLonPoint;
+import org.apache.lucene.document.ShapeField;
+import org.apache.lucene.geo.GeoEncodingUtils;
+import org.apache.lucene.index.DirectoryReader;
+import org.apache.lucene.index.IndexReader;
+import org.apache.lucene.index.IndexWriter;
+import org.apache.lucene.index.IndexWriterConfig;
+import org.apache.lucene.index.SerialMergeScheduler;
+import org.apache.lucene.search.IndexSearcher;
+import org.apache.lucene.search.Query;
+import org.apache.lucene.store.Directory;
+import org.elasticsearch.core.IOUtils;
+import org.elasticsearch.geo.GeometryTestUtils;
+import org.elasticsearch.geometry.Point;
+import org.elasticsearch.h3.CellBoundary;
+import org.elasticsearch.h3.H3;
+import org.elasticsearch.h3.LatLng;
+import org.elasticsearch.test.ESTestCase;
+
+public class H3LatLonGeometryTests extends ESTestCase {
+
+    private static final String FIELD_NAME = "field";
+
+    public void testIndexPoints() throws Exception {
+        Point queryPoint = GeometryTestUtils.randomPoint();
+        String[] hexes = new String[H3.MAX_H3_RES + 1];
+        for (int res = 0; res < hexes.length; res++) {
+            hexes[res] = H3.geoToH3Address(queryPoint.getLat(), queryPoint.getLon(), res);
+        }
+        IndexWriterConfig iwc = newIndexWriterConfig();
+        // Else seeds may not reproduce:
+        iwc.setMergeScheduler(new SerialMergeScheduler());
+        // Else we can get O(N^2) merging:
+        iwc.setMaxBufferedDocs(10);
+        Directory dir = newDirectory();
+        // RandomIndexWriter is too slow here:
+        int[] counts = new int[H3.MAX_H3_RES + 1];
+        IndexWriter w = new IndexWriter(dir, iwc);
+        for (String hex : hexes) {
+            CellBoundary cellBoundary = H3.h3ToGeoBoundary(hex);
+            for (int i = 0; i < cellBoundary.numPoints(); i++) {
+                Document doc = new Document();
+                LatLng latLng = cellBoundary.getLatLon(i);
+                doc.add(new LatLonPoint(FIELD_NAME, latLng.getLatDeg(), latLng.getLonDeg()));
+                w.addDocument(doc);
+                computeCounts(hexes, latLng.getLonDeg(), latLng.getLatDeg(), counts);
+            }
+
+        }
+        final int numDocs = randomIntBetween(1000, 2000);
+        for (int id = 0; id < numDocs; id++) {
+            Document doc = new Document();
+            Point point = GeometryTestUtils.randomPoint();
+            doc.add(new LatLonPoint(FIELD_NAME, point.getLat(), point.getLon()));
+            w.addDocument(doc);
+            computeCounts(hexes, point.getLon(), point.getLat(), counts);
+        }
+
+        if (random().nextBoolean()) {
+            w.forceMerge(1);
+        }
+        final IndexReader r = DirectoryReader.open(w);
+        w.close();
+
+        IndexSearcher s = newSearcher(r);
+        for (int i = 0; i < H3.MAX_H3_RES + 1; i++) {
+            H3LatLonGeometry geometry = new H3LatLonGeometry(hexes[i]);
+            Query indexQuery = LatLonPoint.newGeometryQuery(FIELD_NAME, ShapeField.QueryRelation.INTERSECTS, geometry);
+            assertEquals(counts[i], s.count(indexQuery));
+        }
+        IOUtils.close(r, dir);
+    }
+
+    private void computeCounts(String[] hexes, double lon, double lat, int[] counts) {
+        double qLat = GeoEncodingUtils.decodeLatitude(GeoEncodingUtils.encodeLatitude(lat));
+        double qLon = GeoEncodingUtils.decodeLongitude(GeoEncodingUtils.encodeLongitude(lon));
+        for (int res = 0; res < hexes.length; res++) {
+            if (hexes[res].equals(H3.geoToH3Address(qLat, qLon, res))) {
+                counts[res]++;
+            }
+        }
+    }
+}

+ 14 - 3
x-pack/plugin/spatial/src/test/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/GeoHashTilerTests.java

@@ -7,6 +7,7 @@
 
 package org.elasticsearch.xpack.spatial.search.aggregations.bucket.geogrid;
 
+import org.apache.lucene.geo.GeoEncodingUtils;
 import org.elasticsearch.common.geo.GeoBoundingBox;
 import org.elasticsearch.geometry.Geometry;
 import org.elasticsearch.geometry.Rectangle;
@@ -14,6 +15,7 @@ import org.elasticsearch.geometry.utils.Geohash;
 import org.elasticsearch.search.aggregations.bucket.geogrid.GeoHashBoundedPredicate;
 import org.elasticsearch.xpack.spatial.index.fielddata.GeoRelation;
 import org.elasticsearch.xpack.spatial.index.fielddata.GeoShapeValues;
+import org.elasticsearch.xpack.spatial.index.query.GeoGridQueryBuilder;
 
 import java.io.IOException;
 import java.util.Arrays;
@@ -86,7 +88,7 @@ public class GeoHashTilerTests extends GeoGridTilerTestCase {
         GeoShapeValues.BoundingBox bounds = geoValue.boundingBox();
         if (bounds.minX() == bounds.maxX() && bounds.minY() == bounds.maxY()) {
             String hash = Geohash.stringEncode(bounds.minX(), bounds.minY(), precision);
-            if (hashIntersectsBounds(hash, bbox) && geoValue.relate(Geohash.toBoundingBox(hash)) != GeoRelation.QUERY_DISJOINT) {
+            if (hashIntersectsBounds(hash, bbox) && intersects(hash, geoValue)) {
                 return 1;
             }
             return 0;
@@ -102,8 +104,7 @@ public class GeoHashTilerTests extends GeoGridTilerTestCase {
             if (hashIntersectsBounds(hashes[i], bbox) == false) {
                 continue;
             }
-            GeoRelation relation = geoValue.relate(Geohash.toBoundingBox(hashes[i]));
-            if (relation != GeoRelation.QUERY_DISJOINT) {
+            if (intersects(hashes[i], geoValue)) {
                 if (hashes[i].length() == finalPrecision) {
                     count++;
                 } else {
@@ -114,6 +115,16 @@ public class GeoHashTilerTests extends GeoGridTilerTestCase {
         return count;
     }
 
+    private boolean intersects(String hash, GeoShapeValues.GeoShapeValue geoValue) throws IOException {
+        final Rectangle r = GeoGridQueryBuilder.getQueryHash(hash);
+        return geoValue.relate(
+            GeoEncodingUtils.encodeLongitude(r.getMinLon()),
+            GeoEncodingUtils.encodeLongitude(r.getMaxLon()),
+            GeoEncodingUtils.encodeLatitude(r.getMinLat()),
+            GeoEncodingUtils.encodeLatitude(r.getMaxLat())
+        ) != GeoRelation.QUERY_DISJOINT;
+    }
+
     private boolean hashIntersectsBounds(String hash, GeoBoundingBox bbox) {
         if (bbox == null) {
             return true;

+ 10 - 34
x-pack/plugin/spatial/src/test/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/GeoShapeGeoGridTestCase.java

@@ -38,7 +38,6 @@ import org.elasticsearch.search.aggregations.support.AggregationInspectionHelper
 import org.elasticsearch.search.aggregations.support.CoreValuesSourceType;
 import org.elasticsearch.search.aggregations.support.ValuesSourceType;
 import org.elasticsearch.xpack.spatial.LocalStateSpatialPlugin;
-import org.elasticsearch.xpack.spatial.index.fielddata.GeoRelation;
 import org.elasticsearch.xpack.spatial.index.fielddata.GeoShapeValues;
 import org.elasticsearch.xpack.spatial.index.mapper.BinaryGeoShapeDocValuesField;
 import org.elasticsearch.xpack.spatial.index.mapper.GeoShapeWithDocValuesFieldMapper.GeoShapeWithDocValuesFieldType;
@@ -85,7 +84,12 @@ public abstract class GeoShapeGeoGridTestCase<T extends InternalGeoGridBucket> e
     /**
      * Return the bounding tile as a {@link Rectangle} for a given point
      */
-    protected abstract Rectangle getTile(double lng, double lat, int precision);
+    protected abstract boolean intersects(double lng, double lat, int precision, GeoShapeValues.GeoShapeValue value) throws IOException;
+
+    /**
+     * Return true if the points intersects the bounds
+     */
+    protected abstract boolean intersectsBounds(double lng, double lat, int precision, GeoBoundingBox box);
 
     /**
      * Create a new named {@link GeoGridAggregationBuilder}-derived builder
@@ -158,42 +162,14 @@ public abstract class GeoShapeGeoGridTestCase<T extends InternalGeoGridBucket> e
         expectThrows(IllegalArgumentException.class, () -> builder.precision(-1));
         expectThrows(IllegalArgumentException.class, () -> builder.precision(30));
         GeoBoundingBox bbox = randomBBox();
-        final double boundsTop = bbox.top();
-        final double boundsBottom = bbox.bottom();
-        final double boundsWestLeft;
-        final double boundsWestRight;
-        final double boundsEastLeft;
-        final double boundsEastRight;
-        final boolean crossesDateline;
-        if (bbox.right() < bbox.left()) {
-            boundsWestLeft = -180;
-            boundsWestRight = bbox.right();
-            boundsEastLeft = bbox.left();
-            boundsEastRight = 180;
-            crossesDateline = true;
-        } else { // only set east bounds
-            boundsEastLeft = bbox.left();
-            boundsEastRight = bbox.right();
-            boundsWestLeft = 0;
-            boundsWestRight = 0;
-            crossesDateline = false;
-        }
 
         List<BinaryGeoShapeDocValuesField> docs = new ArrayList<>();
         for (int i = 0; i < numDocs; i++) {
-            Point p;
-            p = randomPoint();
-            double x = GeoTestUtils.encodeDecodeLon(p.getX());
-            double y = GeoTestUtils.encodeDecodeLat(p.getY());
-            Rectangle pointTile = getTile(x, y, precision);
-
+            Point p = randomPoint();
+            double lon = GeoTestUtils.encodeDecodeLon(p.getX());
+            double lat = GeoTestUtils.encodeDecodeLat(p.getY());
             GeoShapeValues.GeoShapeValue value = geoShapeValue(p);
-            GeoRelation tileRelation = value.relate(pointTile);
-            boolean intersectsBounds = boundsTop > pointTile.getMinY()
-                && boundsBottom < pointTile.getMaxY()
-                && (boundsEastLeft < pointTile.getMaxX() && boundsEastRight > pointTile.getMinX()
-                    || (crossesDateline && boundsWestLeft < pointTile.getMaxX() && boundsWestRight > pointTile.getMinX()));
-            if (tileRelation != GeoRelation.QUERY_DISJOINT && intersectsBounds) {
+            if (intersects(lon, lat, precision, value) && intersectsBounds(lon, lat, precision, bbox)) {
                 numDocsWithin += 1;
             }
 

+ 21 - 3
x-pack/plugin/spatial/src/test/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/GeoShapeGeoHashGridAggregatorTests.java

@@ -7,16 +7,22 @@
 
 package org.elasticsearch.xpack.spatial.search.aggregations.bucket.geogrid;
 
+import org.apache.lucene.geo.GeoEncodingUtils;
 import org.elasticsearch.common.geo.GeoBoundingBox;
 import org.elasticsearch.geo.GeometryTestUtils;
 import org.elasticsearch.geometry.Point;
 import org.elasticsearch.geometry.Rectangle;
-import org.elasticsearch.geometry.utils.Geohash;
 import org.elasticsearch.search.aggregations.bucket.geogrid.GeoGridAggregationBuilder;
+import org.elasticsearch.search.aggregations.bucket.geogrid.GeoHashBoundedPredicate;
 import org.elasticsearch.search.aggregations.bucket.geogrid.GeoHashGridAggregationBuilder;
 import org.elasticsearch.search.aggregations.bucket.geogrid.InternalGeoHashGridBucket;
+import org.elasticsearch.xpack.spatial.index.fielddata.GeoRelation;
+import org.elasticsearch.xpack.spatial.index.fielddata.GeoShapeValues;
+import org.elasticsearch.xpack.spatial.index.query.GeoGridQueryBuilder;
 import org.elasticsearch.xpack.spatial.util.GeoTestUtils;
 
+import java.io.IOException;
+
 import static org.elasticsearch.geometry.utils.Geohash.stringEncode;
 
 public class GeoShapeGeoHashGridAggregatorTests extends GeoShapeGeoGridTestCase<InternalGeoHashGridBucket> {
@@ -42,8 +48,20 @@ public class GeoShapeGeoHashGridAggregatorTests extends GeoShapeGeoGridTestCase<
     }
 
     @Override
-    protected Rectangle getTile(double lng, double lat, int precision) {
-        return Geohash.toBoundingBox(stringEncode(lng, lat, precision));
+    protected boolean intersects(double lng, double lat, int precision, GeoShapeValues.GeoShapeValue value) throws IOException {
+        Rectangle boundingBox = GeoGridQueryBuilder.getQueryHash(hashAsString(lng, lat, precision));
+        return value.relate(
+            GeoEncodingUtils.encodeLongitude(boundingBox.getMinLon()),
+            GeoEncodingUtils.encodeLongitude(boundingBox.getMaxLon()),
+            GeoEncodingUtils.encodeLatitude(boundingBox.getMinLat()),
+            GeoEncodingUtils.encodeLatitude(boundingBox.getMaxLat())
+        ) != GeoRelation.QUERY_DISJOINT;
+    }
+
+    @Override
+    protected boolean intersectsBounds(double lng, double lat, int precision, GeoBoundingBox box) {
+        GeoHashBoundedPredicate predicate = new GeoHashBoundedPredicate(precision, box);
+        return predicate.validHash(stringEncode(lng, lat, precision));
     }
 
     @Override

+ 21 - 2
x-pack/plugin/spatial/src/test/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/GeoShapeGeoTileGridAggregatorTests.java

@@ -7,16 +7,23 @@
 
 package org.elasticsearch.xpack.spatial.search.aggregations.bucket.geogrid;
 
+import org.apache.lucene.geo.GeoEncodingUtils;
 import org.elasticsearch.common.geo.GeoBoundingBox;
 import org.elasticsearch.common.geo.GeoUtils;
 import org.elasticsearch.geometry.Point;
 import org.elasticsearch.geometry.Rectangle;
 import org.elasticsearch.search.aggregations.bucket.geogrid.GeoGridAggregationBuilder;
+import org.elasticsearch.search.aggregations.bucket.geogrid.GeoTileBoundedPredicate;
 import org.elasticsearch.search.aggregations.bucket.geogrid.GeoTileGridAggregationBuilder;
 import org.elasticsearch.search.aggregations.bucket.geogrid.GeoTileUtils;
 import org.elasticsearch.search.aggregations.bucket.geogrid.InternalGeoTileGridBucket;
+import org.elasticsearch.xpack.spatial.index.fielddata.GeoRelation;
+import org.elasticsearch.xpack.spatial.index.fielddata.GeoShapeValues;
+import org.elasticsearch.xpack.spatial.index.query.GeoGridQueryBuilder;
 import org.elasticsearch.xpack.spatial.util.GeoTestUtils;
 
+import java.io.IOException;
+
 public class GeoShapeGeoTileGridAggregatorTests extends GeoShapeGeoGridTestCase<InternalGeoTileGridBucket> {
 
     @Override
@@ -54,8 +61,20 @@ public class GeoShapeGeoTileGridAggregatorTests extends GeoShapeGeoGridTestCase<
     }
 
     @Override
-    protected Rectangle getTile(double lng, double lat, int precision) {
-        return GeoTileUtils.toBoundingBox(GeoTileUtils.longEncode(lng, lat, precision));
+    protected boolean intersects(double lng, double lat, int precision, GeoShapeValues.GeoShapeValue value) throws IOException {
+        Rectangle r = GeoGridQueryBuilder.getQueryTile(GeoTileUtils.stringEncode(GeoTileUtils.longEncode(lng, lat, precision)));
+        return value.relate(
+            GeoEncodingUtils.encodeLongitude(r.getMinLon()),
+            GeoEncodingUtils.encodeLongitude(r.getMaxLon()),
+            GeoEncodingUtils.encodeLatitude(r.getMinLat()),
+            GeoEncodingUtils.encodeLatitude(r.getMaxLat())
+        ) != GeoRelation.QUERY_DISJOINT;
+    }
+
+    @Override
+    protected boolean intersectsBounds(double lng, double lat, int precision, GeoBoundingBox box) {
+        GeoTileBoundedPredicate predicate = new GeoTileBoundedPredicate(precision, box);
+        return predicate.validTile(GeoTileUtils.getXTile(lng, 1L << precision), GeoTileUtils.getYTile(lat, 1L << precision), precision);
     }
 
     @Override

+ 18 - 4
x-pack/plugin/spatial/src/test/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/GeoTileTilerTests.java

@@ -7,6 +7,7 @@
 
 package org.elasticsearch.xpack.spatial.search.aggregations.bucket.geogrid;
 
+import org.apache.lucene.geo.GeoEncodingUtils;
 import org.apache.lucene.tests.geo.GeoTestUtil;
 import org.elasticsearch.common.geo.GeoBoundingBox;
 import org.elasticsearch.common.geo.GeoPoint;
@@ -21,10 +22,14 @@ import org.elasticsearch.search.aggregations.bucket.geogrid.GeoTileBoundedPredic
 import org.elasticsearch.search.aggregations.bucket.geogrid.GeoTileUtils;
 import org.elasticsearch.xpack.spatial.index.fielddata.GeoRelation;
 import org.elasticsearch.xpack.spatial.index.fielddata.GeoShapeValues;
+import org.elasticsearch.xpack.spatial.index.query.GeoGridQueryBuilder;
 
+import java.io.IOException;
 import java.util.Arrays;
 
 import static org.elasticsearch.search.aggregations.bucket.geogrid.GeoTileUtils.LATITUDE_MASK;
+import static org.elasticsearch.search.aggregations.bucket.geogrid.GeoTileUtils.longEncodeTiles;
+import static org.elasticsearch.search.aggregations.bucket.geogrid.GeoTileUtils.stringEncode;
 import static org.elasticsearch.xpack.spatial.util.GeoTestUtils.encodeDecodeLat;
 import static org.elasticsearch.xpack.spatial.util.GeoTestUtils.encodeDecodeLon;
 import static org.elasticsearch.xpack.spatial.util.GeoTestUtils.geoShapeValue;
@@ -117,7 +122,7 @@ public class GeoTileTilerTests extends GeoGridTilerTestCase {
             for (int x = minXTileNeg; x <= maxXTileNeg; x++) {
                 for (int y = minYTile; y <= maxYTile; y++) {
                     Rectangle r = GeoTileUtils.toBoundingBox(x, y, precision);
-                    if (tileIntersectsBounds(x, y, precision, bbox) && geoValue.relate(r) != GeoRelation.QUERY_DISJOINT) {
+                    if (tileIntersectsBounds(x, y, precision, bbox) && intersects(x, y, precision, geoValue)) {
                         count += 1;
                     }
                 }
@@ -133,8 +138,7 @@ public class GeoTileTilerTests extends GeoGridTilerTestCase {
 
             for (int x = minXTilePos; x <= maxXTilePos; x++) {
                 for (int y = minYTile; y <= maxYTile; y++) {
-                    Rectangle r = GeoTileUtils.toBoundingBox(x, y, precision);
-                    if (tileIntersectsBounds(x, y, precision, bbox) && geoValue.relate(r) != GeoRelation.QUERY_DISJOINT) {
+                    if (tileIntersectsBounds(x, y, precision, bbox) && intersects(x, y, precision, geoValue)) {
                         count += 1;
                     }
                 }
@@ -151,7 +155,7 @@ public class GeoTileTilerTests extends GeoGridTilerTestCase {
             for (int x = minXTile; x <= maxXTile; x++) {
                 for (int y = minYTile; y <= maxYTile; y++) {
                     Rectangle r = GeoTileUtils.toBoundingBox(x, y, precision);
-                    if (tileIntersectsBounds(x, y, precision, bbox) && geoValue.relate(r) != GeoRelation.QUERY_DISJOINT) {
+                    if (tileIntersectsBounds(x, y, precision, bbox) && intersects(x, y, precision, geoValue)) {
                         count += 1;
                     }
                 }
@@ -160,6 +164,16 @@ public class GeoTileTilerTests extends GeoGridTilerTestCase {
         }
     }
 
+    private boolean intersects(int x, int y, int precision, GeoShapeValues.GeoShapeValue geoValue) throws IOException {
+        Rectangle r = GeoGridQueryBuilder.getQueryTile(stringEncode(longEncodeTiles(precision, x, y)));
+        return geoValue.relate(
+            GeoEncodingUtils.encodeLongitude(r.getMinLon()),
+            GeoEncodingUtils.encodeLongitude(r.getMaxLon()),
+            GeoEncodingUtils.encodeLatitude(r.getMinLat()),
+            GeoEncodingUtils.encodeLatitude(r.getMaxLat())
+        ) != GeoRelation.QUERY_DISJOINT;
+    }
+
     private boolean tileIntersectsBounds(int x, int y, int precision, GeoBoundingBox bbox) {
         if (bbox == null) {
             return true;

+ 98 - 0
x-pack/plugin/src/yamlRestTest/resources/rest-api-spec/test/spatial/90_geo_grid_query.yml

@@ -0,0 +1,98 @@
+setup:
+  - do:
+      indices.create:
+        index: locations
+        body:
+          settings:
+            number_of_shards: 3
+          mappings:
+            properties:
+              location:
+                type: geo_point
+
+  - do:
+      bulk:
+        refresh: true
+        body:
+          - index:
+              _index: locations
+              _id: "1"
+          - '{"location": "POINT(4.912350 52.374081)", "city": "Amsterdam", "name": "NEMO Science Museum"}'
+          - index:
+              _index: locations
+              _id: "2"
+          - '{"location": "POINT(4.901618 52.369219)", "city": "Amsterdam", "name": "Museum Het Rembrandthuis"}'
+          - index:
+              _index: locations
+              _id: "3"
+          - '{"location": "POINT(4.914722 52.371667)", "city": "Amsterdam", "name": "Nederlands Scheepvaartmuseum"}'
+          - index:
+              _index: locations
+              _id: "4"
+          - '{"location": "POINT(4.405200 51.222900)", "city": "Antwerp", "name": "Letterenhuis"}'
+          - index:
+              _index: locations
+              _id: "5"
+          - '{"location": "POINT(2.336389 48.861111)", "city": "Paris", "name": "Musée du Louvre"}'
+          - index:
+              _index: locations
+              _id: "6"
+          - '{"location": "POINT(2.327000 48.860000)", "city": "Paris", "name": "Musée dOrsay"}'
+  - do:
+      indices.refresh: {}
+
+---
+"Test geohash":
+
+  - do:
+      search:
+        index: locations
+        size: 10
+        body:
+          query:
+            geo_grid:
+              location:
+                geohash: "u173zt90zc"
+
+  - match: {hits.total.value:               1    }
+  - match: { hits.hits.0._id:               "2"    }
+  - match: { hits.hits.0._source.city:      "Amsterdam"    }
+  - match: { hits.hits.0._source.name:      "Museum Het Rembrandthuis"    }
+  - match: { hits.hits.0._source.location:  "POINT(4.901618 52.369219)"    }
+---
+"Test geotile":
+
+  - do:
+      search:
+        index: locations
+        size: 10
+        body:
+          query:
+            geo_grid:
+              location:
+                geotile: "22/2154259/1378425"
+
+  - match: {hits.total.value:               1    }
+  - match: { hits.hits.0._id:               "2"    }
+  - match: { hits.hits.0._source.city:      "Amsterdam"    }
+  - match: { hits.hits.0._source.name:      "Museum Het Rembrandthuis"    }
+  - match: { hits.hits.0._source.location:  "POINT(4.901618 52.369219)"    }
+
+---
+"Test geohex":
+
+  - do:
+      search:
+        index: locations
+        size: 10
+        body:
+          query:
+            geo_grid:
+              location:
+                geohex: "8f1969c9b261656"
+
+  - match: {hits.total.value:               1    }
+  - match: { hits.hits.0._id:               "2"    }
+  - match: { hits.hits.0._source.city:      "Amsterdam"    }
+  - match: { hits.hits.0._source.name:      "Museum Het Rembrandthuis"    }
+  - match: { hits.hits.0._source.location:  "POINT(4.901618 52.369219)"    }