diff --git a/docs/changelog/82924.yaml b/docs/changelog/82924.yaml
new file mode 100644
index 0000000000000..d53e2e6bf0ff0
--- /dev/null
+++ b/docs/changelog/82924.yaml
@@ -0,0 +1,5 @@
+pr: 82924
+summary: New `GeoHexGrid` aggregation
+area: Geo
+type: feature
+issues: []
diff --git a/docs/reference/aggregations/bucket.asciidoc b/docs/reference/aggregations/bucket.asciidoc
index dfdaca18e6cfb..af0e338a4588f 100644
--- a/docs/reference/aggregations/bucket.asciidoc
+++ b/docs/reference/aggregations/bucket.asciidoc
@@ -40,6 +40,8 @@ include::bucket/geodistance-aggregation.asciidoc[]
include::bucket/geohashgrid-aggregation.asciidoc[]
+include::bucket/geohexgrid-aggregation.asciidoc[]
+
include::bucket/geotilegrid-aggregation.asciidoc[]
include::bucket/global-aggregation.asciidoc[]
diff --git a/docs/reference/aggregations/bucket/geohexgrid-aggregation.asciidoc b/docs/reference/aggregations/bucket/geohexgrid-aggregation.asciidoc
new file mode 100644
index 0000000000000..528b321cbd90b
--- /dev/null
+++ b/docs/reference/aggregations/bucket/geohexgrid-aggregation.asciidoc
@@ -0,0 +1,249 @@
+[role="xpack"]
+[[search-aggregations-bucket-geohexgrid-aggregation]]
+=== Geohex grid aggregation
+++++
+Geohex grid
+++++
+
+A multi-bucket aggregation that groups <>
+values into buckets that represent a grid.
+The resulting grid can be sparse and only
+contains cells that have matching data. Each cell corresponds to a
+https://h3geo.org/docs/core-library/h3Indexing#h3-cell-indexp[H3 cell index] and is
+labeled using the https://h3geo.org/docs/core-library/h3Indexing#h3index-representation[H3Index representation].
+
+See https://h3geo.org/docs/core-library/restable[the table of cell areas for H3
+resolutions] on how precision (zoom) correlates to size on the ground.
+Precision for this aggregation can be between 0 and 15, inclusive.
+
+WARNING: High-precision requests can be very expensive in terms of RAM and
+result sizes. For example, the highest-precision geohex with a precision of 15
+produces cells that cover less than 10cm by 10cm. We recommend you use a
+filter to limit high-precision requests to a smaller geographic area. For an example,
+refer to <>.
+
+[[geohexgrid-low-precision]]
+==== Simple low-precision request
+
+[source,console,id=geohexgrid-aggregation-example]
+--------------------------------------------------
+PUT /museums
+{
+ "mappings": {
+ "properties": {
+ "location": {
+ "type": "geo_point"
+ }
+ }
+ }
+}
+
+POST /museums/_bulk?refresh
+{"index":{"_id":1}}
+{"location": "52.374081,4.912350", "name": "NEMO Science Museum"}
+{"index":{"_id":2}}
+{"location": "52.369219,4.901618", "name": "Museum Het Rembrandthuis"}
+{"index":{"_id":3}}
+{"location": "52.371667,4.914722", "name": "Nederlands Scheepvaartmuseum"}
+{"index":{"_id":4}}
+{"location": "51.222900,4.405200", "name": "Letterenhuis"}
+{"index":{"_id":5}}
+{"location": "48.861111,2.336389", "name": "Musée du Louvre"}
+{"index":{"_id":6}}
+{"location": "48.860000,2.327000", "name": "Musée d'Orsay"}
+
+POST /museums/_search?size=0
+{
+ "aggregations": {
+ "large-grid": {
+ "geohex_grid": {
+ "field": "location",
+ "precision": 4
+ }
+ }
+ }
+}
+--------------------------------------------------
+
+Response:
+
+[source,console-result]
+--------------------------------------------------
+{
+ ...
+ "aggregations": {
+ "large-grid": {
+ "buckets": [
+ {
+ "key": "841969dffffffff",
+ "doc_count": 3
+ },
+ {
+ "key": "841fb47ffffffff",
+ "doc_count": 2
+ },
+ {
+ "key": "841fa4dffffffff",
+ "doc_count": 1
+ }
+ ]
+ }
+ }
+}
+--------------------------------------------------
+// TESTRESPONSE[s/\.\.\./"took": $body.took,"_shards": $body._shards,"hits":$body.hits,"timed_out":false,/]
+
+[[geohexgrid-high-precision]]
+==== High-precision requests
+
+When requesting detailed buckets (typically for displaying a "zoomed in" map),
+a filter like <> should be
+applied to narrow the subject area. Otherwise, potentially millions of buckets
+will be created and returned.
+
+[source,console,id=geohexgrid-high-precision-ex]
+--------------------------------------------------
+POST /museums/_search?size=0
+{
+ "aggregations": {
+ "zoomed-in": {
+ "filter": {
+ "geo_bounding_box": {
+ "location": {
+ "top_left": "52.4, 4.9",
+ "bottom_right": "52.3, 5.0"
+ }
+ }
+ },
+ "aggregations": {
+ "zoom1": {
+ "geohex_grid": {
+ "field": "location",
+ "precision": 12
+ }
+ }
+ }
+ }
+ }
+}
+--------------------------------------------------
+// TEST[continued]
+
+Response:
+
+[source,console-result]
+--------------------------------------------------
+{
+ ...
+ "aggregations": {
+ "zoomed-in": {
+ "doc_count": 3,
+ "zoom1": {
+ "buckets": [
+ {
+ "key": "8c1969c9b2617ff",
+ "doc_count": 1
+ },
+ {
+ "key": "8c1969526d753ff",
+ "doc_count": 1
+ },
+ {
+ "key": "8c1969526d26dff",
+ "doc_count": 1
+ }
+ ]
+ }
+ }
+ }
+}
+--------------------------------------------------
+// TESTRESPONSE[s/\.\.\./"took": $body.took,"_shards": $body._shards,"hits":$body.hits,"timed_out":false,/]
+
+[[geohexgrid-addtl-bounding-box-filtering]]
+==== Requests with additional bounding box filtering
+
+The `geohex_grid` aggregation supports an optional `bounds` parameter
+that restricts the cells considered to those that intersect the
+provided bounds. The `bounds` parameter accepts the same
+<>
+as the geo-bounding box query. This bounding box can be used with or
+without an additional `geo_bounding_box` query for filtering the points prior to aggregating.
+It is an independent bounding box that can intersect with, be equal to, or be disjoint
+to any additional `geo_bounding_box` queries defined in the context of the aggregation.
+
+[source,console,id=geohexgrid-aggregation-with-bounds]
+--------------------------------------------------
+POST /museums/_search?size=0
+{
+ "aggregations": {
+ "tiles-in-bounds": {
+ "geohex_grid": {
+ "field": "location",
+ "precision": 12,
+ "bounds": {
+ "top_left": "52.4, 4.9",
+ "bottom_right": "52.3, 5.0"
+ }
+ }
+ }
+ }
+}
+--------------------------------------------------
+// TEST[continued]
+
+Response:
+
+[source,console-result]
+--------------------------------------------------
+{
+ ...
+ "aggregations": {
+ "tiles-in-bounds": {
+ "buckets": [
+ {
+ "key": "8c1969c9b2617ff",
+ "doc_count": 1
+ },
+ {
+ "key": "8c1969526d753ff",
+ "doc_count": 1
+ },
+ {
+ "key": "8c1969526d26dff",
+ "doc_count": 1
+ }
+ ]
+ }
+ }
+}
+--------------------------------------------------
+// TESTRESPONSE[s/\.\.\./"took": $body.took,"_shards": $body._shards,"hits":$body.hits,"timed_out":false,/]
+
+[[geohexgrid-options]]
+==== Options
+
+[horizontal]
+field::
+(Required, string) Field containing indexed geo-point values. Must be explicitly
+mapped as a <> field. If the field contains an array,
+`geohex_grid` aggregates all array values.
+
+precision::
+(Optional, integer) Integer zoom of the key used to define cells/buckets in
+the results. Defaults to `6`. Values outside of [`0`,`15`] will be rejected.
+
+bounds::
+(Optional, object) Bounding box used to filter the geo-points in each bucket.
+Accepts the same bounding box formats as the
+<>.
+
+size::
+(Optional, integer) Maximum number of buckets to return. Defaults to 10,000.
+When results are trimmed, buckets are prioritized based on the volume of
+documents they contain.
+
+shard_size::
+(Optional, integer) Number of buckets returned from each shard. Defaults to
+`max(10,(size x number-of-shards))` to allow for more a accurate count of the
+top cells in the final result.
diff --git a/server/src/main/java/org/elasticsearch/search/aggregations/bucket/geogrid/InternalGeoGridBucket.java b/server/src/main/java/org/elasticsearch/search/aggregations/bucket/geogrid/InternalGeoGridBucket.java
index 9c8ac145fca47..126528ef533fc 100644
--- a/server/src/main/java/org/elasticsearch/search/aggregations/bucket/geogrid/InternalGeoGridBucket.java
+++ b/server/src/main/java/org/elasticsearch/search/aggregations/bucket/geogrid/InternalGeoGridBucket.java
@@ -51,7 +51,7 @@ public void writeTo(StreamOutput out) throws IOException {
aggregations.writeTo(out);
}
- protected long hashAsLong() {
+ public long hashAsLong() {
return hashAsLong;
}
diff --git a/server/src/main/java/org/elasticsearch/search/aggregations/bucket/geogrid/ParsedGeoGrid.java b/server/src/main/java/org/elasticsearch/search/aggregations/bucket/geogrid/ParsedGeoGrid.java
index 21a0249c485e2..c7a0f5b184a92 100644
--- a/server/src/main/java/org/elasticsearch/search/aggregations/bucket/geogrid/ParsedGeoGrid.java
+++ b/server/src/main/java/org/elasticsearch/search/aggregations/bucket/geogrid/ParsedGeoGrid.java
@@ -34,7 +34,7 @@ public static ObjectParser createParser(
return parser;
}
- protected void setName(String name) {
+ public void setName(String name) {
super.setName(name);
}
}
diff --git a/test/framework/src/main/java/org/elasticsearch/search/aggregations/bucket/geogrid/GeoGridTestCase.java b/test/framework/src/main/java/org/elasticsearch/search/aggregations/bucket/geogrid/GeoGridTestCase.java
index 20bd74356f7e2..aaddf51eb1735 100644
--- a/test/framework/src/main/java/org/elasticsearch/search/aggregations/bucket/geogrid/GeoGridTestCase.java
+++ b/test/framework/src/main/java/org/elasticsearch/search/aggregations/bucket/geogrid/GeoGridTestCase.java
@@ -55,16 +55,22 @@ protected int maxNumberOfBuckets() {
@Override
protected T createTestInstance(String name, Map metadata, InternalAggregations aggregations) {
final int precision = randomPrecision();
- int size = randomNumberOfBuckets();
- List buckets = new ArrayList<>(size);
+ final int size = randomNumberOfBuckets();
+ final List buckets = new ArrayList<>(size);
+ final List seen = new ArrayList<>(size);
+ int finalSize = 0;
for (int i = 0; i < size; i++) {
double latitude = randomDoubleBetween(-90.0, 90.0, false);
double longitude = randomDoubleBetween(-180.0, 180.0, false);
long hashAsLong = longEncode(longitude, latitude, precision);
- buckets.add(createInternalGeoGridBucket(hashAsLong, randomInt(IndexWriter.MAX_DOCS), aggregations));
+ if (seen.contains(hashAsLong) == false) { // make sure we don't add twice the same bucket
+ buckets.add(createInternalGeoGridBucket(hashAsLong, randomInt(IndexWriter.MAX_DOCS), aggregations));
+ seen.add(hashAsLong);
+ finalSize++;
+ }
}
- return createInternalGeoGrid(name, size, buckets, metadata);
+ return createInternalGeoGrid(name, finalSize, buckets, metadata);
}
@Override
diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/spatial/action/SpatialStatsAction.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/spatial/action/SpatialStatsAction.java
index 4e51f35838cad..cd934c2cd5982 100644
--- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/spatial/action/SpatialStatsAction.java
+++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/spatial/action/SpatialStatsAction.java
@@ -39,7 +39,8 @@ private SpatialStatsAction() {
* Items to track. Serialized by ordinals. Append only, don't remove or change order of items in this list.
*/
public enum Item {
- GEOLINE
+ GEOLINE,
+ GEOHEX
}
public static class Request extends BaseNodesRequest implements ToXContentObject {
diff --git a/x-pack/plugin/spatial/build.gradle b/x-pack/plugin/spatial/build.gradle
index 5c10a7181de2a..7930141230015 100644
--- a/x-pack/plugin/spatial/build.gradle
+++ b/x-pack/plugin/spatial/build.gradle
@@ -14,6 +14,7 @@ dependencies {
compileOnly project(path: ':modules:legacy-geo')
compileOnly project(':modules:lang-painless:spi')
compileOnly project(path: xpackModule('core'))
+ api project(":libs:elasticsearch-h3")
testImplementation(testArtifact(project(xpackModule('core'))))
testImplementation project(path: xpackModule('vector-tile'))
}
diff --git a/x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/SpatialPlugin.java b/x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/SpatialPlugin.java
index 064e43e2b9e90..8c2c260987e65 100644
--- a/x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/SpatialPlugin.java
+++ b/x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/SpatialPlugin.java
@@ -31,6 +31,8 @@
import org.elasticsearch.search.aggregations.metrics.GeoCentroidAggregationBuilder;
import org.elasticsearch.search.aggregations.metrics.ValueCountAggregationBuilder;
import org.elasticsearch.search.aggregations.metrics.ValueCountAggregator;
+import org.elasticsearch.search.aggregations.support.CoreValuesSourceType;
+import org.elasticsearch.search.aggregations.support.ValuesSource;
import org.elasticsearch.search.aggregations.support.ValuesSourceRegistry;
import org.elasticsearch.xcontent.ContextParser;
import org.elasticsearch.xpack.core.XPackPlugin;
@@ -50,9 +52,13 @@
import org.elasticsearch.xpack.spatial.search.aggregations.bucket.geogrid.BoundedGeoHashGridTiler;
import org.elasticsearch.xpack.spatial.search.aggregations.bucket.geogrid.BoundedGeoTileGridTiler;
import org.elasticsearch.xpack.spatial.search.aggregations.bucket.geogrid.GeoGridTiler;
+import org.elasticsearch.xpack.spatial.search.aggregations.bucket.geogrid.GeoHexCellIdSource;
+import org.elasticsearch.xpack.spatial.search.aggregations.bucket.geogrid.GeoHexGridAggregationBuilder;
+import org.elasticsearch.xpack.spatial.search.aggregations.bucket.geogrid.GeoHexGridAggregator;
import org.elasticsearch.xpack.spatial.search.aggregations.bucket.geogrid.GeoShapeCellIdSource;
import org.elasticsearch.xpack.spatial.search.aggregations.bucket.geogrid.GeoShapeHashGridAggregator;
import org.elasticsearch.xpack.spatial.search.aggregations.bucket.geogrid.GeoShapeTileGridAggregator;
+import org.elasticsearch.xpack.spatial.search.aggregations.bucket.geogrid.InternalGeoHexGrid;
import org.elasticsearch.xpack.spatial.search.aggregations.bucket.geogrid.UnboundedGeoHashGridTiler;
import org.elasticsearch.xpack.spatial.search.aggregations.bucket.geogrid.UnboundedGeoTileGridTiler;
import org.elasticsearch.xpack.spatial.search.aggregations.metrics.GeoShapeBoundsAggregator;
@@ -87,6 +93,12 @@ public class SpatialPlugin extends Plugin implements ActionPlugin, MapperPlugin,
License.OperationMode.GOLD
);
+ private final LicensedFeature.Momentary GEO_HEX_AGG_FEATURE = LicensedFeature.momentary(
+ "spatial",
+ "geo-hex-agg",
+ License.OperationMode.GOLD
+ );
+
// to be overriden by tests
protected XPackLicenseState getLicenseState() {
return XPackPlugin.getSharedLicenseState();
@@ -139,7 +151,12 @@ public List getAggregations() {
GeoLineAggregationBuilder.NAME,
GeoLineAggregationBuilder::new,
usage.track(SpatialStatsAction.Item.GEOLINE, checkLicense(GeoLineAggregationBuilder.PARSER, GEO_LINE_AGG_FEATURE))
- ).addResultReader(InternalGeoLine::new).setAggregatorRegistrar(GeoLineAggregationBuilder::registerUsage)
+ ).addResultReader(InternalGeoLine::new).setAggregatorRegistrar(GeoLineAggregationBuilder::registerUsage),
+ new AggregationSpec(
+ GeoHexGridAggregationBuilder.NAME,
+ GeoHexGridAggregationBuilder::new,
+ usage.track(SpatialStatsAction.Item.GEOHEX, checkLicense(GeoHexGridAggregationBuilder.PARSER, GEO_HEX_AGG_FEATURE))
+ ).addResultReader(InternalGeoHexGrid::new).setAggregatorRegistrar(this::registerGeoHexGridAggregator)
);
}
@@ -171,6 +188,47 @@ private void registerGeoShapeCentroidAggregator(ValuesSourceRegistry.Builder bui
);
}
+ private void registerGeoHexGridAggregator(ValuesSourceRegistry.Builder builder) {
+ builder.register(
+ GeoHexGridAggregationBuilder.REGISTRY_KEY,
+ CoreValuesSourceType.GEOPOINT,
+ (
+ name,
+ factories,
+ valuesSource,
+ precision,
+ geoBoundingBox,
+ requiredSize,
+ shardSize,
+ aggregationContext,
+ parent,
+ cardinality,
+ metadata) -> {
+ if (GEO_HEX_AGG_FEATURE.check(getLicenseState())) {
+ GeoHexCellIdSource cellIdSource = new GeoHexCellIdSource(
+ (ValuesSource.GeoPoint) valuesSource,
+ precision,
+ geoBoundingBox
+ );
+ return new GeoHexGridAggregator(
+ name,
+ factories,
+ cellIdSource,
+ requiredSize,
+ shardSize,
+ aggregationContext,
+ parent,
+ cardinality,
+ metadata
+ );
+ }
+
+ throw LicenseUtils.newComplianceException("geohex_grid aggregation on geo_point fields");
+ },
+ true
+ );
+ }
+
private void registerGeoShapeGridAggregators(ValuesSourceRegistry.Builder builder) {
builder.register(
GeoHashGridAggregationBuilder.REGISTRY_KEY,
diff --git a/x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/GeoHexCellIdSource.java b/x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/GeoHexCellIdSource.java
new file mode 100644
index 0000000000000..be2589a007707
--- /dev/null
+++ b/x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/GeoHexCellIdSource.java
@@ -0,0 +1,133 @@
+/*
+ * 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.aggregations.bucket.geogrid;
+
+import org.apache.lucene.index.LeafReaderContext;
+import org.apache.lucene.index.SortedNumericDocValues;
+import org.elasticsearch.common.geo.GeoBoundingBox;
+import org.elasticsearch.h3.CellBoundary;
+import org.elasticsearch.h3.H3;
+import org.elasticsearch.index.fielddata.MultiGeoPointValues;
+import org.elasticsearch.index.fielddata.SortedBinaryDocValues;
+import org.elasticsearch.index.fielddata.SortedNumericDoubleValues;
+import org.elasticsearch.search.aggregations.bucket.geogrid.CellValues;
+import org.elasticsearch.search.aggregations.support.ValuesSource;
+
+/**
+ * Class to help convert {@link MultiGeoPointValues}
+ * to GeoHex bucketing.
+ */
+public class GeoHexCellIdSource extends ValuesSource.Numeric {
+ private final GeoPoint valuesSource;
+ private final int precision;
+ private final GeoBoundingBox geoBoundingBox;
+
+ public GeoHexCellIdSource(GeoPoint valuesSource, int precision, GeoBoundingBox geoBoundingBox) {
+ this.valuesSource = valuesSource;
+ this.precision = precision;
+ this.geoBoundingBox = geoBoundingBox;
+ }
+
+ public int precision() {
+ return precision;
+ }
+
+ @Override
+ public boolean isFloatingPoint() {
+ return false;
+ }
+
+ @Override
+ public SortedNumericDocValues longValues(LeafReaderContext ctx) {
+ return geoBoundingBox.isUnbounded()
+ ? new UnboundedCellValues(valuesSource.geoPointValues(ctx), precision)
+ : new BoundedCellValues(valuesSource.geoPointValues(ctx), precision, geoBoundingBox);
+ }
+
+ @Override
+ public SortedNumericDoubleValues doubleValues(LeafReaderContext ctx) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public SortedBinaryDocValues bytesValues(LeafReaderContext ctx) {
+ throw new UnsupportedOperationException();
+ }
+
+ private static class UnboundedCellValues extends CellValues {
+
+ UnboundedCellValues(MultiGeoPointValues geoValues, int precision) {
+ super(geoValues, precision);
+ }
+
+ @Override
+ protected int advanceValue(org.elasticsearch.common.geo.GeoPoint target, int valuesIdx) {
+ values[valuesIdx] = H3.geoToH3(target.getLat(), target.getLon(), precision);
+ return valuesIdx + 1;
+ }
+ }
+
+ private static class BoundedCellValues extends CellValues {
+
+ private final boolean crossesDateline;
+ private final GeoBoundingBox bbox;
+
+ protected BoundedCellValues(MultiGeoPointValues geoValues, int precision, GeoBoundingBox bbox) {
+ super(geoValues, precision);
+ this.crossesDateline = bbox.right() < bbox.left();
+ this.bbox = bbox;
+ }
+
+ @Override
+ public int advanceValue(org.elasticsearch.common.geo.GeoPoint target, int valuesIdx) {
+ final double lat = target.getLat();
+ final double lon = target.getLon();
+ final long hex = H3.geoToH3(lat, lon, precision);
+ // validPoint is a fast check, validHex is slow
+ if (validPoint(lat, lon) || validHex(hex)) {
+ values[valuesIdx] = hex;
+ return valuesIdx + 1;
+ }
+ return valuesIdx;
+ }
+
+ private boolean validPoint(double lat, double lon) {
+ if (bbox.top() >= lat && bbox.bottom() <= lat) {
+ if (crossesDateline) {
+ return bbox.left() <= lon || bbox.right() >= lon;
+ } else {
+ return bbox.left() <= lon && bbox.right() >= lon;
+ }
+ }
+ return false;
+ }
+
+ private boolean validHex(long hex) {
+ CellBoundary boundary = H3.h3ToGeoBoundary(hex);
+ double minLat = Double.POSITIVE_INFINITY;
+ double minLon = Double.POSITIVE_INFINITY;
+ double maxLat = Double.NEGATIVE_INFINITY;
+ double maxLon = Double.NEGATIVE_INFINITY;
+ for (int i = 0; i < boundary.numPoints(); i++) {
+ double boundaryLat = boundary.getLatLon(i).getLatDeg();
+ double boundaryLon = boundary.getLatLon(i).getLonDeg();
+ minLon = Math.min(minLon, boundaryLon);
+ maxLon = Math.max(maxLon, boundaryLon);
+ minLat = Math.min(minLat, boundaryLat);
+ maxLat = Math.max(maxLat, boundaryLat);
+ }
+ if (bbox.top() > minLat && bbox.bottom() < maxLat) {
+ if (crossesDateline) {
+ return bbox.left() < maxLon || bbox.right() > minLon;
+ } else {
+ return bbox.left() < maxLon && bbox.right() > minLon;
+ }
+ }
+ return false;
+ }
+ }
+}
diff --git a/x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/GeoHexGridAggregationBuilder.java b/x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/GeoHexGridAggregationBuilder.java
new file mode 100644
index 0000000000000..6f9d1a2f509cf
--- /dev/null
+++ b/x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/GeoHexGridAggregationBuilder.java
@@ -0,0 +1,123 @@
+/*
+ * 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.aggregations.bucket.geogrid;
+
+import org.elasticsearch.ElasticsearchParseException;
+import org.elasticsearch.common.geo.GeoBoundingBox;
+import org.elasticsearch.common.io.stream.StreamInput;
+import org.elasticsearch.common.xcontent.support.XContentMapValues;
+import org.elasticsearch.h3.H3;
+import org.elasticsearch.search.aggregations.AggregationBuilder;
+import org.elasticsearch.search.aggregations.AggregatorFactories;
+import org.elasticsearch.search.aggregations.AggregatorFactory;
+import org.elasticsearch.search.aggregations.bucket.geogrid.GeoGridAggregationBuilder;
+import org.elasticsearch.search.aggregations.metrics.GeoGridAggregatorSupplier;
+import org.elasticsearch.search.aggregations.support.AggregationContext;
+import org.elasticsearch.search.aggregations.support.ValuesSourceAggregatorFactory;
+import org.elasticsearch.search.aggregations.support.ValuesSourceConfig;
+import org.elasticsearch.search.aggregations.support.ValuesSourceRegistry;
+import org.elasticsearch.xcontent.ObjectParser;
+import org.elasticsearch.xcontent.XContentParser;
+
+import java.io.IOException;
+import java.util.Map;
+
+public class GeoHexGridAggregationBuilder extends GeoGridAggregationBuilder {
+ public static final String NAME = "geohex_grid";
+ private static final int DEFAULT_PRECISION = 5;
+ private static final int DEFAULT_MAX_NUM_CELLS = 10000;
+ public static final ValuesSourceRegistry.RegistryKey REGISTRY_KEY = new ValuesSourceRegistry.RegistryKey<>(
+ NAME,
+ GeoGridAggregatorSupplier.class
+ );
+
+ public static final ObjectParser PARSER = createParser(
+ NAME,
+ GeoHexGridAggregationBuilder::parsePrecision,
+ GeoHexGridAggregationBuilder::new
+ );
+
+ static int parsePrecision(XContentParser parser) throws IOException, ElasticsearchParseException {
+ final Object node = parser.currentToken().equals(XContentParser.Token.VALUE_NUMBER)
+ ? Integer.valueOf(parser.intValue())
+ : parser.text();
+ return XContentMapValues.nodeIntegerValue(node);
+ }
+
+ public GeoHexGridAggregationBuilder(String name) {
+ super(name);
+ precision(DEFAULT_PRECISION);
+ size(DEFAULT_MAX_NUM_CELLS);
+ shardSize = -1;
+ }
+
+ public GeoHexGridAggregationBuilder(StreamInput in) throws IOException {
+ super(in);
+ }
+
+ @Override
+ public GeoGridAggregationBuilder precision(int precision) {
+ if (precision < 0 || precision > H3.MAX_H3_RES) {
+ throw new IllegalArgumentException(
+ "Invalid geohex aggregation precision of " + precision + "" + ". Must be between 0 and " + H3.MAX_H3_RES
+ );
+ }
+ this.precision = precision;
+ return this;
+ }
+
+ @Override
+ protected ValuesSourceAggregatorFactory createFactory(
+ String name,
+ ValuesSourceConfig config,
+ int precision,
+ int requiredSize,
+ int shardSize,
+ GeoBoundingBox geoBoundingBox,
+ AggregationContext context,
+ AggregatorFactory parent,
+ AggregatorFactories.Builder subFactoriesBuilder,
+ Map metadata
+ ) throws IOException {
+ return new GeoHexGridAggregatorFactory(
+ name,
+ config,
+ precision,
+ requiredSize,
+ shardSize,
+ geoBoundingBox,
+ context,
+ parent,
+ subFactoriesBuilder,
+ metadata
+ );
+ }
+
+ private GeoHexGridAggregationBuilder(
+ GeoHexGridAggregationBuilder clone,
+ AggregatorFactories.Builder factoriesBuilder,
+ Map metadata
+ ) {
+ super(clone, factoriesBuilder, metadata);
+ }
+
+ @Override
+ protected AggregationBuilder shallowCopy(AggregatorFactories.Builder factoriesBuilder, Map metadata) {
+ return new GeoHexGridAggregationBuilder(this, factoriesBuilder, metadata);
+ }
+
+ @Override
+ public String getType() {
+ return NAME;
+ }
+
+ @Override
+ protected ValuesSourceRegistry.RegistryKey> getRegistryKey() {
+ return REGISTRY_KEY;
+ }
+}
diff --git a/x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/GeoHexGridAggregator.java b/x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/GeoHexGridAggregator.java
new file mode 100644
index 0000000000000..5f5239d1624a5
--- /dev/null
+++ b/x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/GeoHexGridAggregator.java
@@ -0,0 +1,62 @@
+/*
+ * 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.aggregations.bucket.geogrid;
+
+import org.elasticsearch.search.aggregations.Aggregator;
+import org.elasticsearch.search.aggregations.AggregatorFactories;
+import org.elasticsearch.search.aggregations.CardinalityUpperBound;
+import org.elasticsearch.search.aggregations.bucket.geogrid.GeoGridAggregator;
+import org.elasticsearch.search.aggregations.bucket.geogrid.InternalGeoGridBucket;
+import org.elasticsearch.search.aggregations.support.AggregationContext;
+import org.elasticsearch.search.aggregations.support.ValuesSource;
+
+import java.io.IOException;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Aggregates data expressed as h3 longs (for efficiency's sake)
+ * but formats results as h3 strings.
+ */
+public class GeoHexGridAggregator extends GeoGridAggregator {
+
+ public GeoHexGridAggregator(
+ String name,
+ AggregatorFactories factories,
+ ValuesSource.Numeric valuesSource,
+ int requiredSize,
+ int shardSize,
+ AggregationContext context,
+ Aggregator parent,
+ CardinalityUpperBound cardinality,
+ Map metadata
+ ) throws IOException {
+ super(name, factories, valuesSource, requiredSize, shardSize, context, parent, cardinality, metadata);
+ }
+
+ @Override
+ protected InternalGeoHexGrid buildAggregation(
+ String name,
+ int requiredSize,
+ List buckets,
+ Map metadata
+ ) {
+ return new InternalGeoHexGrid(name, requiredSize, buckets, metadata);
+ }
+
+ @Override
+ public InternalGeoHexGrid buildEmptyAggregation() {
+ return new InternalGeoHexGrid(name, requiredSize, Collections.emptyList(), metadata());
+ }
+
+ @Override
+ protected InternalGeoGridBucket newEmptyBucket() {
+ return new InternalGeoHexGridBucket(0, 0, null);
+ }
+}
diff --git a/x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/GeoHexGridAggregatorFactory.java b/x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/GeoHexGridAggregatorFactory.java
new file mode 100644
index 0000000000000..4870948bc7e93
--- /dev/null
+++ b/x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/GeoHexGridAggregatorFactory.java
@@ -0,0 +1,81 @@
+/*
+ * 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.aggregations.bucket.geogrid;
+
+import org.elasticsearch.common.geo.GeoBoundingBox;
+import org.elasticsearch.search.aggregations.Aggregator;
+import org.elasticsearch.search.aggregations.AggregatorFactories;
+import org.elasticsearch.search.aggregations.AggregatorFactory;
+import org.elasticsearch.search.aggregations.CardinalityUpperBound;
+import org.elasticsearch.search.aggregations.InternalAggregation;
+import org.elasticsearch.search.aggregations.NonCollectingAggregator;
+import org.elasticsearch.search.aggregations.support.AggregationContext;
+import org.elasticsearch.search.aggregations.support.ValuesSourceAggregatorFactory;
+import org.elasticsearch.search.aggregations.support.ValuesSourceConfig;
+
+import java.io.IOException;
+import java.util.Collections;
+import java.util.Map;
+
+public class GeoHexGridAggregatorFactory extends ValuesSourceAggregatorFactory {
+
+ private final int precision;
+ private final int requiredSize;
+ private final int shardSize;
+ private final GeoBoundingBox geoBoundingBox;
+
+ GeoHexGridAggregatorFactory(
+ String name,
+ ValuesSourceConfig config,
+ int precision,
+ int requiredSize,
+ int shardSize,
+ GeoBoundingBox geoBoundingBox,
+ AggregationContext context,
+ AggregatorFactory parent,
+ AggregatorFactories.Builder subFactoriesBuilder,
+ Map metadata
+ ) throws IOException {
+ super(name, config, context, parent, subFactoriesBuilder, metadata);
+ this.precision = precision;
+ this.requiredSize = requiredSize;
+ this.shardSize = shardSize;
+ this.geoBoundingBox = geoBoundingBox;
+ }
+
+ @Override
+ protected Aggregator createUnmapped(Aggregator parent, Map metadata) throws IOException {
+ final InternalAggregation aggregation = new InternalGeoHexGrid(name, requiredSize, Collections.emptyList(), metadata);
+ return new NonCollectingAggregator(name, context, parent, factories, metadata) {
+ @Override
+ public InternalAggregation buildEmptyAggregation() {
+ return aggregation;
+ }
+ };
+ }
+
+ @Override
+ protected Aggregator doCreateInternal(Aggregator parent, CardinalityUpperBound cardinality, Map metadata)
+ throws IOException {
+ return context.getValuesSourceRegistry()
+ .getAggregator(GeoHexGridAggregationBuilder.REGISTRY_KEY, config)
+ .build(
+ name,
+ factories,
+ config.getValuesSource(),
+ precision,
+ geoBoundingBox,
+ requiredSize,
+ shardSize,
+ context,
+ parent,
+ cardinality,
+ metadata
+ );
+ }
+}
diff --git a/x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/InternalGeoHexGrid.java b/x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/InternalGeoHexGrid.java
new file mode 100644
index 0000000000000..07c5dc35c3e72
--- /dev/null
+++ b/x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/InternalGeoHexGrid.java
@@ -0,0 +1,67 @@
+/*
+ * 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.aggregations.bucket.geogrid;
+
+import org.elasticsearch.common.io.stream.StreamInput;
+import org.elasticsearch.search.aggregations.InternalAggregations;
+import org.elasticsearch.search.aggregations.bucket.geogrid.InternalGeoGrid;
+import org.elasticsearch.search.aggregations.bucket.geogrid.InternalGeoGridBucket;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Represents a grid of cells where each cell's location is determined by a h3 cell.
+ * All cells in a grid are of the same precision and held internally as a single long
+ * for efficiency's sake.
+ */
+public class InternalGeoHexGrid extends InternalGeoGrid {
+
+ InternalGeoHexGrid(String name, int requiredSize, List buckets, Map metadata) {
+ super(name, requiredSize, buckets, metadata);
+ }
+
+ public InternalGeoHexGrid(StreamInput in) throws IOException {
+ super(in);
+ }
+
+ @Override
+ public InternalGeoGrid create(List buckets) {
+ return new InternalGeoHexGrid(name, requiredSize, buckets, metadata);
+ }
+
+ @Override
+ public InternalGeoGridBucket createBucket(InternalAggregations aggregations, InternalGeoGridBucket prototype) {
+ return new InternalGeoHexGridBucket(prototype.hashAsLong(), prototype.getDocCount(), aggregations);
+ }
+
+ @Override
+ protected InternalGeoGrid create(
+ String name,
+ int requiredSize,
+ List buckets,
+ Map metadata
+ ) {
+ return new InternalGeoHexGrid(name, requiredSize, buckets, metadata);
+ }
+
+ @Override
+ protected InternalGeoHexGridBucket createBucket(long hashAsLong, long docCount, InternalAggregations aggregations) {
+ return new InternalGeoHexGridBucket(hashAsLong, docCount, aggregations);
+ }
+
+ @Override
+ protected Reader getBucketReader() {
+ return InternalGeoHexGridBucket::new;
+ }
+
+ @Override
+ public String getWriteableName() {
+ return GeoHexGridAggregationBuilder.NAME;
+ }
+}
diff --git a/x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/InternalGeoHexGridBucket.java b/x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/InternalGeoHexGridBucket.java
new file mode 100644
index 0000000000000..f98b8bfe47627
--- /dev/null
+++ b/x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/InternalGeoHexGridBucket.java
@@ -0,0 +1,42 @@
+/*
+ * 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.aggregations.bucket.geogrid;
+
+import org.elasticsearch.common.geo.GeoPoint;
+import org.elasticsearch.common.io.stream.StreamInput;
+import org.elasticsearch.h3.H3;
+import org.elasticsearch.h3.LatLng;
+import org.elasticsearch.search.aggregations.InternalAggregations;
+import org.elasticsearch.search.aggregations.bucket.geogrid.InternalGeoGridBucket;
+
+import java.io.IOException;
+
+public class InternalGeoHexGridBucket extends InternalGeoGridBucket {
+
+ InternalGeoHexGridBucket(long hashAsLong, long docCount, InternalAggregations aggregations) {
+ super(hashAsLong, docCount, aggregations);
+ }
+
+ /**
+ * Read from a stream.
+ */
+ public InternalGeoHexGridBucket(StreamInput in) throws IOException {
+ super(in);
+ }
+
+ @Override
+ public String getKeyAsString() {
+ return H3.h3ToString(hashAsLong);
+ }
+
+ @Override
+ public GeoPoint getKey() {
+ LatLng latLng = H3.h3ToLatLng(hashAsLong);
+ return new GeoPoint(latLng.getLatDeg(), latLng.getLonDeg());
+ }
+}
diff --git a/x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/ParsedGeoHexGrid.java b/x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/ParsedGeoHexGrid.java
new file mode 100644
index 0000000000000..ae8c878391405
--- /dev/null
+++ b/x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/ParsedGeoHexGrid.java
@@ -0,0 +1,34 @@
+/*
+ * 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.aggregations.bucket.geogrid;
+
+import org.elasticsearch.search.aggregations.bucket.geogrid.ParsedGeoGrid;
+import org.elasticsearch.xcontent.ObjectParser;
+import org.elasticsearch.xcontent.XContentParser;
+
+import java.io.IOException;
+
+public class ParsedGeoHexGrid extends ParsedGeoGrid {
+
+ private static final ObjectParser PARSER = createParser(
+ ParsedGeoHexGrid::new,
+ ParsedGeoHexGridBucket::fromXContent,
+ ParsedGeoHexGridBucket::fromXContent
+ );
+
+ public static ParsedGeoGrid fromXContent(XContentParser parser, String name) throws IOException {
+ ParsedGeoGrid aggregation = PARSER.parse(parser, null);
+ aggregation.setName(name);
+ return aggregation;
+ }
+
+ @Override
+ public String getType() {
+ return GeoHexGridAggregationBuilder.NAME;
+ }
+}
diff --git a/x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/ParsedGeoHexGridBucket.java b/x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/ParsedGeoHexGridBucket.java
new file mode 100644
index 0000000000000..1383e46dcd9e5
--- /dev/null
+++ b/x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/ParsedGeoHexGridBucket.java
@@ -0,0 +1,34 @@
+/*
+ * 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.aggregations.bucket.geogrid;
+
+import org.elasticsearch.common.geo.GeoPoint;
+import org.elasticsearch.h3.H3;
+import org.elasticsearch.h3.LatLng;
+import org.elasticsearch.search.aggregations.bucket.geogrid.ParsedGeoGridBucket;
+import org.elasticsearch.xcontent.XContentParser;
+
+import java.io.IOException;
+
+public class ParsedGeoHexGridBucket extends ParsedGeoGridBucket {
+
+ @Override
+ public GeoPoint getKey() {
+ LatLng latLng = H3.h3ToLatLng(hashAsString);
+ return new GeoPoint(latLng.getLatDeg(), latLng.getLonDeg());
+ }
+
+ @Override
+ public String getKeyAsString() {
+ return hashAsString;
+ }
+
+ static ParsedGeoHexGridBucket fromXContent(XContentParser parser) throws IOException {
+ return parseXContent(parser, false, ParsedGeoHexGridBucket::new, (p, bucket) -> bucket.hashAsString = p.text());
+ }
+}
diff --git a/x-pack/plugin/spatial/src/test/java/org/elasticsearch/xpack/spatial/SpatialPluginTests.java b/x-pack/plugin/spatial/src/test/java/org/elasticsearch/xpack/spatial/SpatialPluginTests.java
index 2ea9a4205ba5a..8ca7afd4b69d3 100644
--- a/x-pack/plugin/spatial/src/test/java/org/elasticsearch/xpack/spatial/SpatialPluginTests.java
+++ b/x-pack/plugin/spatial/src/test/java/org/elasticsearch/xpack/spatial/SpatialPluginTests.java
@@ -10,15 +10,19 @@
import org.elasticsearch.license.License;
import org.elasticsearch.license.TestUtils;
import org.elasticsearch.license.XPackLicenseState;
+import org.elasticsearch.plugins.SearchPlugin;
+import org.elasticsearch.search.aggregations.AggregatorFactories;
import org.elasticsearch.search.aggregations.CardinalityUpperBound;
import org.elasticsearch.search.aggregations.bucket.geogrid.GeoHashGridAggregationBuilder;
import org.elasticsearch.search.aggregations.bucket.geogrid.GeoTileGridAggregationBuilder;
import org.elasticsearch.search.aggregations.metrics.GeoCentroidAggregationBuilder;
import org.elasticsearch.search.aggregations.metrics.GeoGridAggregatorSupplier;
import org.elasticsearch.search.aggregations.metrics.MetricAggregatorSupplier;
+import org.elasticsearch.search.aggregations.support.CoreValuesSourceType;
import org.elasticsearch.search.aggregations.support.ValuesSourceConfig;
import org.elasticsearch.search.aggregations.support.ValuesSourceRegistry;
import org.elasticsearch.test.ESTestCase;
+import org.elasticsearch.xpack.spatial.search.aggregations.bucket.geogrid.GeoHexGridAggregationBuilder;
import org.elasticsearch.xpack.spatial.search.aggregations.support.GeoShapeValuesSourceType;
import java.util.Arrays;
@@ -54,6 +58,43 @@ public void testGeoCentroidLicenseCheck() {
}
}
+ public void testGeoHexLicenseCheck() {
+ for (License.OperationMode operationMode : License.OperationMode.values()) {
+ SpatialPlugin plugin = getPluginWithOperationMode(operationMode);
+ ValuesSourceRegistry.Builder registryBuilder = new ValuesSourceRegistry.Builder();
+ List specs = plugin.getAggregations();
+ specs.forEach(c -> c.getAggregatorRegistrar().accept(registryBuilder));
+ ValuesSourceRegistry registry = registryBuilder.build();
+ GeoGridAggregatorSupplier hexSupplier = registry.getAggregator(
+ GeoHexGridAggregationBuilder.REGISTRY_KEY,
+ new ValuesSourceConfig(CoreValuesSourceType.GEOPOINT, null, true, null, null, null, null, null, null)
+ );
+ if (License.OperationMode.TRIAL != operationMode
+ && License.OperationMode.compare(operationMode, License.OperationMode.GOLD) < 0) {
+ ElasticsearchSecurityException exception = expectThrows(
+ ElasticsearchSecurityException.class,
+ () -> hexSupplier.build(
+ null,
+ AggregatorFactories.EMPTY,
+ null,
+ 0,
+ null,
+ 0,
+ 0,
+ null,
+ null,
+ CardinalityUpperBound.NONE,
+ null
+ )
+ );
+ assertThat(
+ exception.getMessage(),
+ equalTo("current license is non-compliant for [geohex_grid aggregation on geo_point fields]")
+ );
+ }
+ }
+ }
+
public void testGeoGridLicenseCheck() {
for (ValuesSourceRegistry.RegistryKey registryKey : Arrays.asList(
GeoHashGridAggregationBuilder.REGISTRY_KEY,
diff --git a/x-pack/plugin/spatial/src/test/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/GeoHexAggregationBuilderTests.java b/x-pack/plugin/spatial/src/test/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/GeoHexAggregationBuilderTests.java
new file mode 100644
index 0000000000000..dbe960087d91d
--- /dev/null
+++ b/x-pack/plugin/spatial/src/test/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/GeoHexAggregationBuilderTests.java
@@ -0,0 +1,65 @@
+/*
+ * 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.aggregations.bucket.geogrid;
+
+import org.elasticsearch.common.io.stream.Writeable;
+import org.elasticsearch.h3.H3;
+import org.elasticsearch.test.AbstractSerializingTestCase;
+import org.elasticsearch.xcontent.XContentParser;
+import org.elasticsearch.xpack.spatial.util.GeoTestUtils;
+
+import java.io.IOException;
+
+import static org.hamcrest.Matchers.equalTo;
+
+public class GeoHexAggregationBuilderTests extends AbstractSerializingTestCase {
+
+ @Override
+ protected GeoHexGridAggregationBuilder doParseInstance(XContentParser parser) throws IOException {
+ assertThat(parser.nextToken(), equalTo(XContentParser.Token.START_OBJECT));
+ assertThat(parser.nextToken(), equalTo(XContentParser.Token.FIELD_NAME));
+ String name = parser.currentName();
+ assertThat(parser.nextToken(), equalTo(XContentParser.Token.START_OBJECT));
+ assertThat(parser.nextToken(), equalTo(XContentParser.Token.FIELD_NAME));
+ assertThat(parser.currentName(), equalTo(GeoHexGridAggregationBuilder.NAME));
+ GeoHexGridAggregationBuilder parsed = GeoHexGridAggregationBuilder.PARSER.apply(parser, name);
+ assertThat(parser.nextToken(), equalTo(XContentParser.Token.END_OBJECT));
+ assertThat(parser.nextToken(), equalTo(XContentParser.Token.END_OBJECT));
+ return parsed;
+ }
+
+ @Override
+ protected Writeable.Reader instanceReader() {
+ return GeoHexGridAggregationBuilder::new;
+ }
+
+ @Override
+ protected GeoHexGridAggregationBuilder createTestInstance() {
+ GeoHexGridAggregationBuilder geoHexGridAggregationBuilder = new GeoHexGridAggregationBuilder("_name");
+ geoHexGridAggregationBuilder.field("field");
+ if (randomBoolean()) {
+ geoHexGridAggregationBuilder.precision(randomIntBetween(0, H3.MAX_H3_RES));
+ }
+ if (randomBoolean()) {
+ geoHexGridAggregationBuilder.size(randomIntBetween(0, 256 * 256));
+ }
+ if (randomBoolean()) {
+ geoHexGridAggregationBuilder.shardSize(randomIntBetween(0, 256 * 256));
+ }
+ if (randomBoolean()) {
+ geoHexGridAggregationBuilder.setGeoBoundingBox(GeoTestUtils.randomBBox());
+ }
+ return geoHexGridAggregationBuilder;
+ }
+
+ public void testInvalidPrecision() {
+ GeoHexGridAggregationBuilder geoHexGridAggregationBuilder = new GeoHexGridAggregationBuilder("_name");
+ expectThrows(IllegalArgumentException.class, () -> geoHexGridAggregationBuilder.precision(16));
+ expectThrows(IllegalArgumentException.class, () -> geoHexGridAggregationBuilder.precision(-1));
+ }
+}
diff --git a/x-pack/plugin/spatial/src/test/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/GeoHexAggregatorTests.java b/x-pack/plugin/spatial/src/test/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/GeoHexAggregatorTests.java
new file mode 100644
index 0000000000000..18ec429a0c3a1
--- /dev/null
+++ b/x-pack/plugin/spatial/src/test/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/GeoHexAggregatorTests.java
@@ -0,0 +1,87 @@
+/*
+ * 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.aggregations.bucket.geogrid;
+
+import org.elasticsearch.common.geo.GeoBoundingBox;
+import org.elasticsearch.geo.GeometryTestUtils;
+import org.elasticsearch.geometry.Point;
+import org.elasticsearch.geometry.Rectangle;
+import org.elasticsearch.h3.CellBoundary;
+import org.elasticsearch.h3.H3;
+import org.elasticsearch.index.mapper.MappedFieldType;
+import org.elasticsearch.plugins.SearchPlugin;
+import org.elasticsearch.search.aggregations.AggregationBuilder;
+import org.elasticsearch.search.aggregations.bucket.geogrid.GeoGridAggregationBuilder;
+import org.elasticsearch.search.aggregations.bucket.geogrid.GeoGridAggregatorTestCase;
+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.search.aggregations.support.GeoShapeValuesSourceType;
+import org.elasticsearch.xpack.spatial.util.GeoTestUtils;
+
+import java.util.List;
+
+public class GeoHexAggregatorTests extends GeoGridAggregatorTestCase {
+
+ @Override
+ protected List getSearchPlugins() {
+ return List.of(new LocalStateSpatialPlugin());
+ }
+
+ @Override
+ protected List getSupportedValuesSourceTypes() {
+ return List.of(GeoShapeValuesSourceType.instance(), CoreValuesSourceType.GEOPOINT);
+ }
+
+ @Override
+ protected int randomPrecision() {
+ return randomIntBetween(0, H3.MAX_H3_RES);
+ }
+
+ @Override
+ protected String hashAsString(double lng, double lat, int precision) {
+ return H3.geoToH3Address(lat, lng, precision);
+ }
+
+ @Override
+ protected GeoGridAggregationBuilder createBuilder(String name) {
+ return new GeoHexGridAggregationBuilder(name);
+ }
+
+ @Override
+ protected Point randomPoint() {
+ return GeometryTestUtils.randomPoint();
+ }
+
+ @Override
+ protected GeoBoundingBox randomBBox() {
+ return GeoTestUtils.randomBBox();
+ }
+
+ @Override
+ protected Rectangle getTile(double lng, double lat, int precision) {
+ CellBoundary boundary = H3.h3ToGeoBoundary(hashAsString(lng, lat, precision));
+ double minLat = Double.POSITIVE_INFINITY;
+ double minLon = Double.POSITIVE_INFINITY;
+ double maxLat = Double.NEGATIVE_INFINITY;
+ double maxLon = Double.NEGATIVE_INFINITY;
+ for (int i = 0; i < boundary.numPoints(); i++) {
+ double boundaryLat = boundary.getLatLon(i).getLatDeg();
+ double boundaryLon = boundary.getLatLon(i).getLonDeg();
+ minLon = Math.min(minLon, boundaryLon);
+ maxLon = Math.max(maxLon, boundaryLon);
+ minLat = Math.min(minLat, boundaryLat);
+ maxLat = Math.max(maxLat, boundaryLat);
+ }
+ return new Rectangle(minLon, maxLon, maxLat, minLat);
+ }
+
+ @Override
+ protected AggregationBuilder createAggBuilderForTypeTest(MappedFieldType fieldType, String fieldName) {
+ return createBuilder("foo").field(fieldName);
+ }
+}
diff --git a/x-pack/plugin/spatial/src/test/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/GeoHexGridTests.java b/x-pack/plugin/spatial/src/test/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/GeoHexGridTests.java
new file mode 100644
index 0000000000000..421e014452024
--- /dev/null
+++ b/x-pack/plugin/spatial/src/test/java/org/elasticsearch/xpack/spatial/search/aggregations/bucket/geogrid/GeoHexGridTests.java
@@ -0,0 +1,66 @@
+/*
+ * 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.aggregations.bucket.geogrid;
+
+import org.elasticsearch.common.util.CollectionUtils;
+import org.elasticsearch.h3.H3;
+import org.elasticsearch.plugins.SearchPlugin;
+import org.elasticsearch.search.aggregations.Aggregation;
+import org.elasticsearch.search.aggregations.InternalAggregations;
+import org.elasticsearch.search.aggregations.bucket.geogrid.GeoGridTestCase;
+import org.elasticsearch.search.aggregations.bucket.geogrid.InternalGeoGridBucket;
+import org.elasticsearch.xcontent.NamedXContentRegistry;
+import org.elasticsearch.xcontent.ParseField;
+import org.elasticsearch.xpack.spatial.LocalStateSpatialPlugin;
+
+import java.util.List;
+import java.util.Map;
+
+public class GeoHexGridTests extends GeoGridTestCase {
+
+ @Override
+ protected SearchPlugin registerPlugin() {
+ return new LocalStateSpatialPlugin();
+ }
+
+ @Override
+ protected List getNamedXContents() {
+ return CollectionUtils.appendToCopy(
+ super.getNamedXContents(),
+ new NamedXContentRegistry.Entry(
+ Aggregation.class,
+ new ParseField(GeoHexGridAggregationBuilder.NAME),
+ (p, c) -> ParsedGeoHexGrid.fromXContent(p, (String) c)
+ )
+ );
+ }
+
+ @Override
+ protected InternalGeoHexGrid createInternalGeoGrid(
+ String name,
+ int size,
+ List buckets,
+ Map metadata
+ ) {
+ return new InternalGeoHexGrid(name, size, buckets, metadata);
+ }
+
+ @Override
+ protected InternalGeoHexGridBucket createInternalGeoGridBucket(Long key, long docCount, InternalAggregations aggregations) {
+ return new InternalGeoHexGridBucket(key, docCount, aggregations);
+ }
+
+ @Override
+ protected long longEncode(double lng, double lat, int precision) {
+ return H3.geoToH3(lat, lng, precision);
+ }
+
+ @Override
+ protected int randomPrecision() {
+ return randomIntBetween(0, H3.MAX_H3_RES);
+ }
+}
diff --git a/x-pack/plugin/src/yamlRestTest/resources/rest-api-spec/test/spatial/80_geohex_grid.yml b/x-pack/plugin/src/yamlRestTest/resources/rest-api-spec/test/spatial/80_geohex_grid.yml
new file mode 100644
index 0000000000000..a578e80ad4ddd
--- /dev/null
+++ b/x-pack/plugin/src/yamlRestTest/resources/rest-api-spec/test/spatial/80_geohex_grid.yml
@@ -0,0 +1,143 @@
+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 geohex_grid with defaults":
+
+ - do:
+ search:
+ index: locations
+ size: 0
+ body:
+ aggs:
+ grid:
+ geohex_grid:
+ field: location
+ - match: {hits.total.value: 6 }
+ - length: { aggregations.grid.buckets: 3 }
+ - match: { aggregations.grid.buckets.0.key: "85196953fffffff" }
+ - match: { aggregations.grid.buckets.0.doc_count: 3 }
+ - match: { aggregations.grid.buckets.1.key: "851fb467fffffff" }
+ - match: { aggregations.grid.buckets.1.doc_count: 2 }
+ - match: { aggregations.grid.buckets.2.key: "851fa4c7fffffff" }
+ - match: { aggregations.grid.buckets.2.doc_count: 1 }
+
+---
+"Test geohex_grid with precision":
+
+ - do:
+ search:
+ index: locations
+ size: 0
+ body:
+ aggs:
+ grid:
+ geohex_grid:
+ field: location
+ precision: 0
+ - match: { hits.total.value: 6 }
+ - length: { aggregations.grid.buckets: 2 }
+ - match: { aggregations.grid.buckets.0.key: "801ffffffffffff" }
+ - match: { aggregations.grid.buckets.0.doc_count: 4 }
+ - match: { aggregations.grid.buckets.1.key: "8019fffffffffff" }
+ - match: { aggregations.grid.buckets.1.doc_count: 2 }
+
+---
+"Test geohex_grid with size":
+
+ - do:
+ search:
+ index: locations
+ size: 0
+ body:
+ aggs:
+ grid:
+ geohex_grid:
+ field: location
+ size: 1
+ - match: {hits.total.value: 6 }
+ - length: { aggregations.grid.buckets: 1 }
+ - match: { aggregations.grid.buckets.0.key: "85196953fffffff" }
+ - match: { aggregations.grid.buckets.0.doc_count: 3 }
+
+---
+"Test geohex_grid with shard size":
+
+ - do:
+ search:
+ index: locations
+ size: 0
+ body:
+ aggs:
+ grid:
+ geohex_grid:
+ field: location
+ shard_size: 10
+ - match: {hits.total.value: 6 }
+ - length: { aggregations.grid.buckets: 3 }
+ - match: { aggregations.grid.buckets.0.key: "85196953fffffff" }
+ - match: { aggregations.grid.buckets.0.doc_count: 3 }
+ - match: { aggregations.grid.buckets.1.key: "851fb467fffffff" }
+ - match: { aggregations.grid.buckets.1.doc_count: 2 }
+ - match: { aggregations.grid.buckets.2.key: "851fa4c7fffffff" }
+ - match: { aggregations.grid.buckets.2.doc_count: 1 }
+
+---
+"Test geohex_grid with bounds":
+
+ - do:
+ search:
+ index: locations
+ size: 0
+ body:
+ aggs:
+ grid:
+ geohex_grid:
+ field: location
+ bounds:
+ top_left: "52.4, 4.9"
+ bottom_right: "52.3, 5.0"
+ - match: {hits.total.value: 6 }
+ - length: { aggregations.grid.buckets: 1 }
+ - match: { aggregations.grid.buckets.0.key: "85196953fffffff" }
+ - match: { aggregations.grid.buckets.0.doc_count: 3 }