Skip to content

Commit ab1d891

Browse files
authored
Support for store parameter in geo_shape field (#94418)
This commit adds support for storing geo_shape fields as a lucene stored field. The geometry will be stored normalised in well-known binary format (WKB).
1 parent 6b8e953 commit ab1d891

File tree

17 files changed

+353
-40
lines changed

17 files changed

+353
-40
lines changed

docs/changelog/94418.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
pr: 94418
2+
summary: Support for store parameter in `geo_shape` field
3+
area: Geo
4+
type: enhancement
5+
issues:
6+
- 83655

libs/geo/src/main/java/org/elasticsearch/geometry/utils/GeometryValidator.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
*/
1616
public interface GeometryValidator {
1717

18+
GeometryValidator NOOP = (geometry) -> {};
19+
1820
/**
1921
* Validates the geometry and throws IllegalArgumentException if the geometry is not valid
2022
*/

libs/geo/src/main/java/org/elasticsearch/geometry/utils/WellKnownBinary.java

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -199,8 +199,15 @@ private static void writeDouble(ByteArrayOutputStream out, ByteBuffer scratch, d
199199
/**
200200
* Reads a {@link Geometry} from the given WKB byte array.
201201
*/
202-
public static Geometry fromWKB(GeometryValidator validator, boolean coerce, byte[] wkb) throws IOException {
203-
final ByteBuffer byteBuffer = ByteBuffer.wrap(wkb);
202+
public static Geometry fromWKB(GeometryValidator validator, boolean coerce, byte[] wkb) {
203+
return fromWKB(validator, coerce, wkb, 0, wkb.length);
204+
}
205+
206+
/**
207+
* Reads a {@link Geometry} from the given WKB byte array with offset.
208+
*/
209+
public static Geometry fromWKB(GeometryValidator validator, boolean coerce, byte[] wkb, int offset, int length) {
210+
final ByteBuffer byteBuffer = ByteBuffer.wrap(wkb, offset, length);
204211
final Geometry geometry = parseGeometry(byteBuffer, coerce);
205212
validator.validate(geometry);
206213
return geometry;

libs/geo/src/test/java/org/elasticsearch/geometry/utils/WKBTests.java

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -141,8 +141,18 @@ private ByteOrder randomByteOrder() {
141141
private void assertWKB(Geometry geometry) throws IOException {
142142
final boolean hasZ = geometry.hasZ();
143143
final ByteOrder byteOrder = randomByteOrder();
144-
byte[] b = WellKnownBinary.toWKB(geometry, byteOrder);
145-
assertEquals(geometry, WellKnownBinary.fromWKB(StandardValidator.instance(hasZ), randomBoolean(), b));
144+
final byte[] b = WellKnownBinary.toWKB(geometry, byteOrder);
145+
if (randomBoolean()) {
146+
// add padding to the byte array
147+
final int extraBytes = randomIntBetween(1, 500);
148+
final byte[] oversizeB = new byte[b.length + extraBytes];
149+
random().nextBytes(oversizeB);
150+
final int offset = randomInt(extraBytes);
151+
System.arraycopy(b, 0, oversizeB, offset, b.length);
152+
assertEquals(geometry, WellKnownBinary.fromWKB(StandardValidator.instance(hasZ), randomBoolean(), oversizeB, offset, b.length));
153+
} else {
154+
assertEquals(geometry, WellKnownBinary.fromWKB(StandardValidator.instance(hasZ), randomBoolean(), b));
155+
}
146156
}
147157

148158
}

server/src/main/java/org/elasticsearch/index/mapper/GeoShapeIndexer.java

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,14 +46,23 @@ public GeoShapeIndexer(Orientation orientation, String name) {
4646
this.name = name;
4747
}
4848

49+
@Override
4950
public List<IndexableField> indexShape(Geometry geometry) {
5051
if (geometry == null) {
5152
return Collections.emptyList();
5253
}
53-
if (GeometryNormalizer.needsNormalize(orientation, geometry)) {
54-
geometry = GeometryNormalizer.apply(orientation, geometry);
55-
}
56-
LuceneGeometryIndexer visitor = new LuceneGeometryIndexer(name);
54+
return getIndexableFields(normalize(geometry));
55+
}
56+
57+
/** Normalise the geometry, that is make sure latitude and longitude are between expected values
58+
* and split geometries across the dateline when needed */
59+
public Geometry normalize(Geometry geometry) {
60+
return GeometryNormalizer.needsNormalize(orientation, geometry) ? GeometryNormalizer.apply(orientation, geometry) : geometry;
61+
}
62+
63+
/** Generates lucene indexable fields from a geometry. It expects geometries that have already been normalised. */
64+
public List<IndexableField> getIndexableFields(Geometry geometry) {
65+
final LuceneGeometryIndexer visitor = new LuceneGeometryIndexer(name);
5766
geometry.visit(visitor);
5867
return visitor.fields();
5968
}

server/src/main/java/org/elasticsearch/index/mapper/StoredValueFetcher.java

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
/**
2020
* Value fetcher that loads from stored values.
2121
*/
22-
public final class StoredValueFetcher implements ValueFetcher {
22+
public class StoredValueFetcher implements ValueFetcher {
2323

2424
private final SearchLookup lookup;
2525
private LeafSearchLookup leafSearchLookup;
@@ -42,8 +42,15 @@ public List<Object> fetchValues(Source source, int doc, List<Object> ignoredValu
4242
if (values == null) {
4343
return values;
4444
} else {
45-
return List.copyOf(values);
45+
return parseStoredValues(List.copyOf(values));
4646
}
4747
}
4848

49+
/**
50+
* Given the values stored in lucene, parse it into a standard format.
51+
*/
52+
protected List<Object> parseStoredValues(List<Object> values) {
53+
return values;
54+
}
55+
4956
}

test/framework/src/main/java/org/elasticsearch/index/mapper/FieldTypeTestCase.java

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@
99

1010
import org.elasticsearch.Version;
1111
import org.elasticsearch.index.query.SearchExecutionContext;
12+
import org.elasticsearch.search.lookup.FieldLookup;
13+
import org.elasticsearch.search.lookup.LeafSearchLookup;
14+
import org.elasticsearch.search.lookup.LeafStoredFieldsLookup;
1215
import org.elasticsearch.search.lookup.SearchLookup;
1316
import org.elasticsearch.search.lookup.Source;
1417
import org.elasticsearch.test.ESTestCase;
@@ -68,4 +71,22 @@ public static List<?> fetchSourceValues(MappedFieldType fieldType, Object... val
6871
Source source = Source.fromMap(Collections.singletonMap(field, List.of(values)), randomFrom(XContentType.values()));
6972
return fetcher.fetchValues(source, -1, new ArrayList<>());
7073
}
74+
75+
public static List<?> fetchStoredValue(MappedFieldType fieldType, List<Object> storedValues, String format) throws IOException {
76+
String field = fieldType.name();
77+
SearchExecutionContext searchExecutionContext = mock(SearchExecutionContext.class);
78+
SearchLookup searchLookup = mock(SearchLookup.class);
79+
LeafSearchLookup leafSearchLookup = mock(LeafSearchLookup.class);
80+
LeafStoredFieldsLookup leafStoredFieldsLookup = mock(LeafStoredFieldsLookup.class);
81+
FieldLookup fieldLookup = mock(FieldLookup.class);
82+
when(searchExecutionContext.lookup()).thenReturn(searchLookup);
83+
when(searchLookup.getLeafSearchLookup(null)).thenReturn(leafSearchLookup);
84+
when(leafSearchLookup.fields()).thenReturn(leafStoredFieldsLookup);
85+
when(leafStoredFieldsLookup.get(field)).thenReturn(fieldLookup);
86+
when(fieldLookup.getValues()).thenReturn(storedValues);
87+
88+
ValueFetcher fetcher = fieldType.valueFetcher(searchExecutionContext, format);
89+
fetcher.setNextReader(null);
90+
return fetcher.fetchValues(null, -1, new ArrayList<>());
91+
}
7192
}

x-pack/plugin/spatial/src/internalClusterTest/java/org/elasticsearch/xpack/spatial/search/GeoShapeWithDocValuesIT.java

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,20 +7,27 @@
77

88
package org.elasticsearch.xpack.spatial.search;
99

10+
import org.apache.lucene.util.BytesRef;
1011
import org.elasticsearch.Version;
1112
import org.elasticsearch.action.search.SearchResponse;
1213
import org.elasticsearch.common.bytes.BytesReference;
1314
import org.elasticsearch.common.unit.DistanceUnit;
15+
import org.elasticsearch.geometry.Geometry;
1416
import org.elasticsearch.geometry.LinearRing;
1517
import org.elasticsearch.geometry.Polygon;
18+
import org.elasticsearch.geometry.ShapeType;
19+
import org.elasticsearch.geometry.utils.StandardValidator;
20+
import org.elasticsearch.geometry.utils.WellKnownBinary;
1621
import org.elasticsearch.index.mapper.MapperParsingException;
1722
import org.elasticsearch.percolator.PercolateQueryBuilder;
1823
import org.elasticsearch.percolator.PercolatorPlugin;
1924
import org.elasticsearch.plugins.Plugin;
25+
import org.elasticsearch.search.SearchHit;
2026
import org.elasticsearch.search.geo.GeoShapeIntegTestCase;
2127
import org.elasticsearch.search.sort.SortOrder;
2228
import org.elasticsearch.test.VersionUtils;
2329
import org.elasticsearch.xcontent.XContentBuilder;
30+
import org.elasticsearch.xcontent.XContentFactory;
2431
import org.elasticsearch.xcontent.XContentType;
2532
import org.elasticsearch.xpack.spatial.LocalStateSpatialPlugin;
2633

@@ -36,6 +43,7 @@
3643
import static org.elasticsearch.xcontent.XContentFactory.jsonBuilder;
3744
import static org.hamcrest.Matchers.containsString;
3845
import static org.hamcrest.Matchers.equalTo;
46+
import static org.hamcrest.Matchers.instanceOf;
3947

4048
public class GeoShapeWithDocValuesIT extends GeoShapeIntegTestCase {
4149

@@ -158,4 +166,44 @@ public void testPercolatorGeoQueries() throws Exception {
158166
assertThat(response.getHits().getAt(1).getId(), equalTo("2"));
159167
assertThat(response.getHits().getAt(2).getId(), equalTo("3"));
160168
}
169+
170+
// make sure we store the normalised geometry
171+
public void testStorePolygonDateLine() throws Exception {
172+
XContentBuilder mapping = XContentFactory.jsonBuilder().startObject().startObject("properties").startObject("shape");
173+
getGeoShapeMapping(mapping);
174+
mapping.field("store", true);
175+
mapping.endObject().endObject().endObject();
176+
177+
// create index
178+
assertAcked(
179+
client().admin()
180+
.indices()
181+
.prepareCreate("test")
182+
.setSettings(settings(randomSupportedVersion()).build())
183+
.setMapping(mapping)
184+
.get()
185+
);
186+
ensureGreen();
187+
188+
String source = """
189+
{
190+
"shape": "POLYGON((179 0, -179 0, -179 2, 179 2, 179 0))"
191+
}""";
192+
193+
indexRandom(true, client().prepareIndex("test").setId("0").setSource(source, XContentType.JSON));
194+
195+
SearchResponse searchResponse = client().prepareSearch("test").setFetchSource(false).addStoredField("shape").get();
196+
assertThat(searchResponse.getHits().getTotalHits().value, equalTo(1L));
197+
SearchHit searchHit = searchResponse.getHits().getAt(0);
198+
assertThat(searchHit.field("shape").getValue(), instanceOf(BytesRef.class));
199+
BytesRef bytesRef = searchHit.field("shape").getValue();
200+
Geometry geometry = WellKnownBinary.fromWKB(
201+
StandardValidator.instance(true),
202+
false,
203+
bytesRef.bytes,
204+
bytesRef.offset,
205+
bytesRef.length
206+
);
207+
assertThat(geometry.type(), equalTo(ShapeType.MULTIPOLYGON));
208+
}
161209
}

x-pack/plugin/spatial/src/main/java/org/elasticsearch/xpack/spatial/index/mapper/GeoShapeWithDocValuesFieldMapper.java

Lines changed: 52 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,21 +8,26 @@
88
package org.elasticsearch.xpack.spatial.index.mapper;
99

1010
import org.apache.lucene.document.LatLonShape;
11+
import org.apache.lucene.document.StoredField;
1112
import org.apache.lucene.geo.LatLonGeometry;
1213
import org.apache.lucene.index.IndexableField;
1314
import org.apache.lucene.search.IndexOrDocValuesQuery;
1415
import org.apache.lucene.search.Query;
16+
import org.apache.lucene.util.BytesRef;
1517
import org.elasticsearch.Version;
1618
import org.elasticsearch.common.Explicit;
1719
import org.elasticsearch.common.geo.GeoBoundingBox;
1820
import org.elasticsearch.common.geo.GeoFormatterFactory;
1921
import org.elasticsearch.common.geo.GeoPoint;
22+
import org.elasticsearch.common.geo.GeometryFormatterFactory;
2023
import org.elasticsearch.common.geo.GeometryParser;
2124
import org.elasticsearch.common.geo.Orientation;
2225
import org.elasticsearch.common.geo.ShapeRelation;
2326
import org.elasticsearch.common.logging.DeprecationCategory;
2427
import org.elasticsearch.common.logging.DeprecationLogger;
2528
import org.elasticsearch.geometry.Geometry;
29+
import org.elasticsearch.geometry.utils.GeometryValidator;
30+
import org.elasticsearch.geometry.utils.WellKnownBinary;
2631
import org.elasticsearch.index.fielddata.FieldDataContext;
2732
import org.elasticsearch.index.fielddata.IndexFieldData;
2833
import org.elasticsearch.index.fielddata.ScriptDocValues;
@@ -38,6 +43,8 @@
3843
import org.elasticsearch.index.mapper.MapperBuilderContext;
3944
import org.elasticsearch.index.mapper.MapperParsingException;
4045
import org.elasticsearch.index.mapper.MappingParserContext;
46+
import org.elasticsearch.index.mapper.StoredValueFetcher;
47+
import org.elasticsearch.index.mapper.ValueFetcher;
4148
import org.elasticsearch.index.query.QueryShardException;
4249
import org.elasticsearch.index.query.SearchExecutionContext;
4350
import org.elasticsearch.legacygeo.mapper.LegacyGeoShapeFieldMapper;
@@ -52,6 +59,8 @@
5259

5360
import java.io.IOException;
5461
import java.io.UncheckedIOException;
62+
import java.nio.ByteOrder;
63+
import java.util.ArrayList;
5564
import java.util.Arrays;
5665
import java.util.Iterator;
5766
import java.util.List;
@@ -94,6 +103,7 @@ private static Builder builder(FieldMapper in) {
94103
public static class Builder extends FieldMapper.Builder {
95104

96105
final Parameter<Boolean> indexed = Parameter.indexParam(m -> builder(m).indexed.get(), true);
106+
final Parameter<Boolean> stored = Parameter.storeParam(m -> builder(m).stored.get(), false);
97107
final Parameter<Boolean> hasDocValues;
98108

99109
final Parameter<Explicit<Boolean>> ignoreMalformed;
@@ -121,9 +131,15 @@ public Builder(
121131
this.hasDocValues = Parameter.docValuesParam(m -> builder(m).hasDocValues.get(), Version.V_7_8_0.onOrBefore(version));
122132
}
123133

134+
// for testing
135+
protected Builder setStored(boolean stored) {
136+
this.stored.setValue(stored);
137+
return this;
138+
}
139+
124140
@Override
125141
protected Parameter<?>[] getParameters() {
126-
return new Parameter<?>[] { indexed, hasDocValues, ignoreMalformed, ignoreZValue, coerce, orientation, meta };
142+
return new Parameter<?>[] { indexed, hasDocValues, stored, ignoreMalformed, ignoreZValue, coerce, orientation, meta };
127143
}
128144

129145
@Override
@@ -145,6 +161,7 @@ public GeoShapeWithDocValuesFieldMapper build(MapperBuilderContext context) {
145161
context.buildFullName(name),
146162
indexed.get(),
147163
hasDocValues.get(),
164+
stored.get(),
148165
orientation.get().value(),
149166
parser,
150167
geoFormatterFactory,
@@ -171,12 +188,13 @@ public GeoShapeWithDocValuesFieldType(
171188
String name,
172189
boolean indexed,
173190
boolean hasDocValues,
191+
boolean isStored,
174192
Orientation orientation,
175193
GeoShapeParser parser,
176194
GeoFormatterFactory<Geometry> geoFormatterFactory,
177195
Map<String, String> meta
178196
) {
179-
super(name, indexed, false, hasDocValues, parser, orientation, meta);
197+
super(name, indexed, isStored, hasDocValues, parser, orientation, meta);
180198
this.geoFormatterFactory = geoFormatterFactory;
181199
}
182200

@@ -218,6 +236,30 @@ public Query geoShapeQuery(SearchExecutionContext context, String fieldName, Sha
218236
return query;
219237
}
220238

239+
@Override
240+
public ValueFetcher valueFetcher(SearchExecutionContext context, String format) {
241+
if (isStored()) {
242+
Function<List<Geometry>, List<Object>> formatter = getFormatter(format != null ? format : GeometryFormatterFactory.GEOJSON);
243+
return new StoredValueFetcher(context.lookup(), name()) {
244+
@Override
245+
public List<Object> parseStoredValues(List<Object> storedValues) {
246+
final List<Geometry> values = new ArrayList<>(storedValues.size());
247+
for (Object storedValue : storedValues) {
248+
if (storedValue instanceof BytesRef bytesRef) {
249+
values.add(
250+
WellKnownBinary.fromWKB(GeometryValidator.NOOP, false, bytesRef.bytes, bytesRef.offset, bytesRef.length)
251+
);
252+
} else {
253+
throw new IllegalArgumentException("Unexpected class fetching [" + name() + "]: " + storedValue.getClass());
254+
}
255+
}
256+
return formatter.apply(values);
257+
}
258+
};
259+
}
260+
return super.valueFetcher(context, format);
261+
}
262+
221263
@Override
222264
protected Function<List<Geometry>, List<Object>> getFormatter(String format) {
223265
return geoFormatterFactory.getFormatter(format, Function.identity());
@@ -303,21 +345,27 @@ protected void index(DocumentParserContext context, Geometry geometry) throws IO
303345
if (geometry == null) {
304346
return;
305347
}
306-
List<IndexableField> fields = indexer.indexShape(geometry);
348+
final Geometry normalizedGeometry = indexer.normalize(geometry);
349+
final List<IndexableField> fields = indexer.getIndexableFields(normalizedGeometry);
307350
if (fieldType().isIndexed()) {
308351
context.doc().addAll(fields);
309352
}
310353
if (fieldType().hasDocValues()) {
311-
String name = fieldType().name();
354+
final String name = fieldType().name();
312355
BinaryShapeDocValuesField docValuesField = (BinaryShapeDocValuesField) context.doc().getByKey(name);
313356
if (docValuesField == null) {
314357
docValuesField = new BinaryShapeDocValuesField(name, CoordinateEncoder.GEO);
315358
context.doc().addWithKey(name, docValuesField);
316359
}
360+
// we need to pass the original geometry to compute more precisely the centroid, e.g if lon > 180
317361
docValuesField.add(fields, geometry);
318362
} else if (fieldType().isIndexed()) {
319363
context.addToFieldNames(fieldType().name());
320364
}
365+
366+
if (fieldType().isStored()) {
367+
context.doc().add(new StoredField(fieldType().name(), WellKnownBinary.toWKB(normalizedGeometry, ByteOrder.LITTLE_ENDIAN)));
368+
}
321369
}
322370

323371
@Override

0 commit comments

Comments
 (0)