diff --git a/src/main/asciidoc/reference/elasticsearch-misc.adoc b/src/main/asciidoc/reference/elasticsearch-misc.adoc index d35522f75..1cba145e2 100644 --- a/src/main/asciidoc/reference/elasticsearch-misc.adoc +++ b/src/main/asciidoc/reference/elasticsearch-misc.adoc @@ -80,3 +80,17 @@ while (stream.hasNext()) { } ---- ==== + +[[elasticsearch.misc.sorts]] +== Sort options + +In addition to the default sort options described <> Spring Data Elasticsearch has a `GeoDistanceOrder` class which can be used to have the result of a search operation ordered by geographical distance. + +If the class to be retrieved has a `GeoPoint` property named _location_, the following `Sort` would sort the results by distance to the given point: + +[source,java] +---- +Sort.by(new GeoDistanceOrder("location", new GeoPoint(48.137154, 11.5761247))) +---- + + diff --git a/src/main/asciidoc/reference/elasticsearch-new.adoc b/src/main/asciidoc/reference/elasticsearch-new.adoc index 807d3d62d..7f4720779 100644 --- a/src/main/asciidoc/reference/elasticsearch-new.adoc +++ b/src/main/asciidoc/reference/elasticsearch-new.adoc @@ -12,6 +12,7 @@ * Cleanup of the API in the `*Operations` interfaces, grouping and renaming methods so that they match the Elasticsearch API, deprecating the old methods, aligning with other Spring Data modules. * Introduction of `SearchHit` class to represent a found document together with the relevant result metadata for this document (i.e. _sortValues_). * Introduction of the `SearchHits` class to represent a whole search result together with the metadata for the complete search result (i.e. _max_score_). +* Introduction of the `GeoDistanceOrder` class to be able to create sorting by geographical distance [[new-features.3-2-0]] == New in Spring Data Elasticsearch 3.2 diff --git a/src/main/java/org/springframework/data/elasticsearch/core/RequestFactory.java b/src/main/java/org/springframework/data/elasticsearch/core/RequestFactory.java index e2ffaa038..2af47e775 100644 --- a/src/main/java/org/springframework/data/elasticsearch/core/RequestFactory.java +++ b/src/main/java/org/springframework/data/elasticsearch/core/RequestFactory.java @@ -41,6 +41,8 @@ import org.elasticsearch.client.Client; import org.elasticsearch.client.Requests; import org.elasticsearch.client.indices.CreateIndexRequest; import org.elasticsearch.client.indices.PutMappingRequest; +import org.elasticsearch.common.geo.GeoDistance; +import org.elasticsearch.common.unit.DistanceUnit; import org.elasticsearch.common.unit.TimeValue; import org.elasticsearch.common.xcontent.XContentBuilder; import org.elasticsearch.common.xcontent.XContentType; @@ -56,9 +58,11 @@ import org.elasticsearch.search.builder.SearchSourceBuilder; import org.elasticsearch.search.fetch.subphase.FetchSourceContext; import org.elasticsearch.search.fetch.subphase.highlight.HighlightBuilder; import org.elasticsearch.search.sort.FieldSortBuilder; +import org.elasticsearch.search.sort.GeoDistanceSortBuilder; import org.elasticsearch.search.sort.ScoreSortBuilder; import org.elasticsearch.search.sort.SortBuilder; import org.elasticsearch.search.sort.SortBuilders; +import org.elasticsearch.search.sort.SortMode; import org.elasticsearch.search.sort.SortOrder; import org.springframework.data.domain.Sort; import org.springframework.data.elasticsearch.ElasticsearchException; @@ -771,17 +775,29 @@ class RequestFactory { : null; String fieldName = property != null ? property.getFieldName() : order.getProperty(); - FieldSortBuilder sort = SortBuilders // - .fieldSort(fieldName) // - .order(sortOrder); + if (order instanceof GeoDistanceOrder) { + GeoDistanceOrder geoDistanceOrder = (GeoDistanceOrder) order; - if (order.getNullHandling() == Sort.NullHandling.NULLS_FIRST) { - sort.missing("_first"); - } else if (order.getNullHandling() == Sort.NullHandling.NULLS_LAST) { - sort.missing("_last"); + GeoDistanceSortBuilder sort = SortBuilders.geoDistanceSort(fieldName, geoDistanceOrder.getGeoPoint().getLat(), + geoDistanceOrder.getGeoPoint().getLon()); + + sort.geoDistance(GeoDistance.fromString(geoDistanceOrder.getDistanceType().name())); + sort.ignoreUnmapped(geoDistanceOrder.getIgnoreUnmapped()); + sort.sortMode(SortMode.fromString(geoDistanceOrder.getMode().name())); + sort.unit(DistanceUnit.fromString(geoDistanceOrder.getUnit())); + return sort; + } else { + FieldSortBuilder sort = SortBuilders // + .fieldSort(fieldName) // + .order(sortOrder); + + if (order.getNullHandling() == Sort.NullHandling.NULLS_FIRST) { + sort.missing("_first"); + } else if (order.getNullHandling() == Sort.NullHandling.NULLS_LAST) { + sort.missing("_last"); + } + return sort; } - - return sort; } } diff --git a/src/main/java/org/springframework/data/elasticsearch/core/geo/GeoPoint.java b/src/main/java/org/springframework/data/elasticsearch/core/geo/GeoPoint.java index 248ec12cf..5d2c33a70 100644 --- a/src/main/java/org/springframework/data/elasticsearch/core/geo/GeoPoint.java +++ b/src/main/java/org/springframework/data/elasticsearch/core/geo/GeoPoint.java @@ -22,6 +22,7 @@ import org.springframework.data.geo.Point; * * @author Franck Marchand * @author Mohsin Husen + * @author Peter-Josef Meisch */ public class GeoPoint { @@ -58,6 +59,14 @@ public class GeoPoint { public static Point toPoint(GeoPoint point) { return new Point(point.getLat(), point.getLon()); } + + @Override + public String toString() { + return "GeoPoint{" + + "lat=" + lat + + ", lon=" + lon + + '}'; + } } diff --git a/src/main/java/org/springframework/data/elasticsearch/core/query/GeoDistanceOrder.java b/src/main/java/org/springframework/data/elasticsearch/core/query/GeoDistanceOrder.java new file mode 100644 index 000000000..b3c436f2f --- /dev/null +++ b/src/main/java/org/springframework/data/elasticsearch/core/query/GeoDistanceOrder.java @@ -0,0 +1,126 @@ +/* + * Copyright 2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.elasticsearch.core.query; + +import org.springframework.data.domain.Sort; +import org.springframework.data.elasticsearch.core.geo.GeoPoint; + +/** + * {@link org.springframework.data.domain.Sort.Order} derived class to be able to define a _geo_distance order for a + * search. + * + * @author Peter-Josef Meisch + * @since 4.0 + */ +public class GeoDistanceOrder extends Sort.Order { + + private static final DistanceType DEFAULT_DISTANCE_TYPE = DistanceType.arc; + private static final Mode DEFAULT_MODE = Mode.min; + private static final String DEFAULT_UNIT = "m"; + private static final Boolean DEFAULT_IGNORE_UNMAPPED = false; + + private final GeoPoint geoPoint; + private final DistanceType distanceType; + private final Mode mode; + private final String unit; + private final Boolean ignoreUnmapped; + + public GeoDistanceOrder(String property, GeoPoint geoPoint) { + this(property, geoPoint, Sort.Direction.ASC, DEFAULT_DISTANCE_TYPE, DEFAULT_MODE, DEFAULT_UNIT, + DEFAULT_IGNORE_UNMAPPED); + } + + private GeoDistanceOrder(String property, GeoPoint geoPoint, Sort.Direction direction, DistanceType distanceType, + Mode mode, String unit, Boolean ignoreUnmapped) { + super(direction, property); + this.geoPoint = geoPoint; + this.distanceType = distanceType; + this.mode = mode; + this.unit = unit; + this.ignoreUnmapped = ignoreUnmapped; + } + + public GeoPoint getGeoPoint() { + return geoPoint; + } + + public DistanceType getDistanceType() { + return distanceType; + } + + public Mode getMode() { + return mode; + } + + public String getUnit() { + return unit; + } + + public Boolean getIgnoreUnmapped() { + return ignoreUnmapped; + } + + @Override + public GeoDistanceOrder withProperty(String property) { + return new GeoDistanceOrder(property, getGeoPoint(), getDirection(), getDistanceType(), getMode(), getUnit(), + getIgnoreUnmapped()); + } + + @Override + public GeoDistanceOrder with(Sort.Direction direction) { + return new GeoDistanceOrder(getProperty(), getGeoPoint(), direction, getDistanceType(), getMode(), getUnit(), + getIgnoreUnmapped()); + } + + @Override + public GeoDistanceOrder with(Sort.NullHandling nullHandling) { + throw new UnsupportedOperationException("null handling is not supported for _geo_distance sorts"); + } + + public GeoDistanceOrder with(DistanceType distanceType) { + return new GeoDistanceOrder(getProperty(), getGeoPoint(), getDirection(), distanceType, getMode(), getUnit(), + getIgnoreUnmapped()); + } + + public GeoDistanceOrder with(Mode mode) { + return new GeoDistanceOrder(getProperty(), getGeoPoint(), getDirection(), getDistanceType(), mode, getUnit(), + getIgnoreUnmapped()); + } + + public GeoDistanceOrder withUnit(String unit) { + return new GeoDistanceOrder(getProperty(), getGeoPoint(), getDirection(), getDistanceType(), getMode(), unit, + getIgnoreUnmapped()); + } + + public GeoDistanceOrder withIgnoreUnmapped(Boolean ignoreUnmapped) { + return new GeoDistanceOrder(getProperty(), getGeoPoint(), getDirection(), getDistanceType(), getMode(), getUnit(), + ignoreUnmapped); + } + + @Override + public String toString() { + return "GeoDistanceOrder{" + "geoPoint=" + geoPoint + ", distanceType=" + distanceType + ", mode=" + mode + + ", unit='" + unit + '\'' + ", ignoreUnmapped=" + ignoreUnmapped + "} " + super.toString(); + } + + public enum DistanceType { + arc, plane + } + + public enum Mode { + min, max, median, avg + } +} diff --git a/src/test/java/org/springframework/data/elasticsearch/core/CriteriaQueryMappingTests.java b/src/test/java/org/springframework/data/elasticsearch/core/CriteriaQueryMappingTests.java index 0a39f55a6..6fe403e81 100644 --- a/src/test/java/org/springframework/data/elasticsearch/core/CriteriaQueryMappingTests.java +++ b/src/test/java/org/springframework/data/elasticsearch/core/CriteriaQueryMappingTests.java @@ -36,7 +36,7 @@ import org.springframework.data.elasticsearch.core.query.CriteriaQuery; /** * Tests for the mapping of {@link CriteriaQuery} by a * {@link org.springframework.data.elasticsearch.core.convert.MappingElasticsearchConverter}. In the same package as - * {@link CriteriaQueryProcessor} as this is needed to get the String represenation to assert. + * {@link CriteriaQueryProcessor} as this is needed to get the String representation to assert. * * @author Peter-Josef Meisch */ @@ -59,8 +59,8 @@ public class CriteriaQueryMappingTests { void shouldMapNamesAndConvertValuesInCriteriaQuery() throws JSONException { // use POJO properties and types in the query building - CriteriaQuery criteriaQuery = new CriteriaQuery( - new Criteria("birthDate").between(LocalDate.of(1989, 11, 9), LocalDate.of(1990, 11, 9)).or("birthDate").is(LocalDate.of(2019, 12, 28))); + CriteriaQuery criteriaQuery = new CriteriaQuery(new Criteria("birthDate") + .between(LocalDate.of(1989, 11, 9), LocalDate.of(1990, 11, 9)).or("birthDate").is(LocalDate.of(2019, 12, 28))); // mapped field name and converted parameter String expected = '{' + // diff --git a/src/test/java/org/springframework/data/elasticsearch/core/RequestFactoryTest.java b/src/test/java/org/springframework/data/elasticsearch/core/RequestFactoryTest.java new file mode 100644 index 000000000..ddd144944 --- /dev/null +++ b/src/test/java/org/springframework/data/elasticsearch/core/RequestFactoryTest.java @@ -0,0 +1,111 @@ +/* + * Copyright 2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.elasticsearch.core; + +import static org.skyscreamer.jsonassert.JSONAssert.*; + +import java.util.Collections; + +import org.json.JSONException; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.springframework.core.convert.support.GenericConversionService; +import org.springframework.data.annotation.Id; +import org.springframework.data.domain.Sort; +import org.springframework.data.elasticsearch.annotations.Field; +import org.springframework.data.elasticsearch.core.convert.MappingElasticsearchConverter; +import org.springframework.data.elasticsearch.core.geo.GeoPoint; +import org.springframework.data.elasticsearch.core.mapping.IndexCoordinates; +import org.springframework.data.elasticsearch.core.mapping.SimpleElasticsearchMappingContext; +import org.springframework.data.elasticsearch.core.query.Criteria; +import org.springframework.data.elasticsearch.core.query.CriteriaQuery; +import org.springframework.data.elasticsearch.core.query.GeoDistanceOrder; + +/** + * @author Peter-Josef Meisch + */ +class RequestFactoryTest { + + private static RequestFactory requestFactory; + private static MappingElasticsearchConverter converter; + + @BeforeAll + + static void setUpAll() { + SimpleElasticsearchMappingContext mappingContext = new SimpleElasticsearchMappingContext(); + mappingContext.setInitialEntitySet(Collections.singleton(Person.class)); + mappingContext.afterPropertiesSet(); + + converter = new MappingElasticsearchConverter(mappingContext, new GenericConversionService()); + converter.afterPropertiesSet(); + + requestFactory = new RequestFactory((converter)); + } + + @Test // FPI-734 + void shouldBuildSearchWithGeoSortSort() throws JSONException { + CriteriaQuery query = new CriteriaQuery(new Criteria("lastName").is("Smith")); + Sort sort = Sort.by(new GeoDistanceOrder("location", new GeoPoint(49.0, 8.4))); + query.addSort(sort); + + converter.updateQuery(query, Person.class); + + String expected = '{' + // + " \"query\": {" + // + " \"bool\": {" + // + " \"must\": [" + // + " {" + // + " \"query_string\": {" + // + " \"query\": \"Smith\"," + // + " \"fields\": [" + // + " \"first-name^1.0\"" + // + " ]" + // + " }" + // + " }" + // + " ]" + // + " }" + // + " }," + // + " \"sort\": [" + // + " {" + // + " \"_geo_distance\": {" + // + " \"current-location\": [" + // + " {" + // + " \"lat\": 49.0," + // + " \"lon\": 8.4" + // + " }" + // + " ]," + // + " \"unit\": \"m\"," + // + " \"distance_type\": \"arc\"," + // + " \"order\": \"asc\"," + // + " \"mode\": \"min\"," + // + " \"ignore_unmapped\": false" + // + " }" + // + " }" + // + " ]" + // + '}'; + + String searchRequest = requestFactory.searchRequest(query, Person.class, IndexCoordinates.of("persons")).source() + .toString(); + + assertEquals(expected, searchRequest, false); + } + + static class Person { + @Id String id; + @Field(name = "last-name") String lastName; + @Field(name = "current-location") GeoPoint location; + } +} diff --git a/src/test/java/org/springframework/data/elasticsearch/repositories/custommethod/CustomMethodRepositoryBaseTests.java b/src/test/java/org/springframework/data/elasticsearch/repositories/custommethod/CustomMethodRepositoryBaseTests.java index 0c514d5fa..75f0e044b 100644 --- a/src/test/java/org/springframework/data/elasticsearch/repositories/custommethod/CustomMethodRepositoryBaseTests.java +++ b/src/test/java/org/springframework/data/elasticsearch/repositories/custommethod/CustomMethodRepositoryBaseTests.java @@ -54,6 +54,7 @@ import org.springframework.data.elasticsearch.core.SearchHit; import org.springframework.data.elasticsearch.core.SearchHits; import org.springframework.data.elasticsearch.core.geo.GeoBox; import org.springframework.data.elasticsearch.core.geo.GeoPoint; +import org.springframework.data.elasticsearch.core.query.GeoDistanceOrder; import org.springframework.data.elasticsearch.junit.jupiter.SpringIntegrationTest; import org.springframework.data.elasticsearch.repository.ElasticsearchRepository; import org.springframework.data.elasticsearch.utils.IndexInitializer; @@ -1441,6 +1442,40 @@ public abstract class CustomMethodRepositoryBaseTests { assertThat(searchHit.getHighlightField("type")).hasSize(1).contains("abc"); } + @Test // DATAES-734 + void shouldUseGeoSortParameter() { + GeoPoint munich = new GeoPoint(48.137154, 11.5761247); + GeoPoint berlin = new GeoPoint(52.520008, 13.404954); + GeoPoint vienna = new GeoPoint(48.20849, 16.37208); + GeoPoint oslo = new GeoPoint(59.9127, 10.7461); + + List entities = new ArrayList<>(); + + SampleEntity entity1 = new SampleEntity(); + entity1.setId("berlin"); + entity1.setLocation(berlin); + entities.add(entity1); + + SampleEntity entity2 = new SampleEntity(); + entity2.setId("vienna"); + entity2.setLocation(vienna); + entities.add(entity2); + + SampleEntity entity3 = new SampleEntity(); + entity3.setId("oslo"); + entity3.setLocation(oslo); + entities.add(entity3); + + repository.saveAll(entities); + + SearchHits searchHits = repository.searchBy(Sort.by(new GeoDistanceOrder("location", munich))); + + assertThat(searchHits.getTotalHits()).isEqualTo(3); + assertThat(searchHits.getSearchHit(0).getId()).isEqualTo("vienna"); + assertThat(searchHits.getSearchHit(1).getId()).isEqualTo("berlin"); + assertThat(searchHits.getSearchHit(2).getId()).isEqualTo("oslo"); + } + private List createSampleEntities(String type, int numberOfEntities) { List entities = new ArrayList<>(); @@ -1588,6 +1623,7 @@ public abstract class CustomMethodRepositoryBaseTests { Stream> readByMessage(String message); + SearchHits searchBy(Sort sort); } /**