Add support for nested sort.

Original Pull Request #2653
Closes #1783
This commit is contained in:
Peter-Josef Meisch 2023-07-30 16:29:52 +00:00 committed by GitHub
parent 076f261a7d
commit bd71a9311c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 709 additions and 306 deletions

View File

@ -419,3 +419,34 @@ public class PersonCustomRepositoryImpl implements PersonCustomRepository {
<.> The parameters are passed in a `Map<String,Object>`
<.> Do the search in the same way as with the other query types.
====
[[elasticsearch.misc.nested-sort]]
== Nested sort
Spring Data Elasticsearch supports sorting within nested objects (https://www.elastic.co/guide/en/elasticsearch/reference/8.9/sort-search-results.html#nested-sorting)
The following example, taken from the `org.springframework.data.elasticsearch.core.query.sort.NestedSortIntegrationTests` class, shows how to define the nested sort.
====
[source,java]
----
var filter = StringQuery.builder("""
{ "term": {"movies.actors.sex": "m"} }
""").build();
var order = new org.springframework.data.elasticsearch.core.query.Order(Sort.Direction.DESC,
"movies.actors.yearOfBirth")
.withNested(
Nested.builder("movies")
.withNested(
Nested.builder("movies.actors")
.withFilter(filter)
.build())
.build());
var query = Query.findAll().addSort(Sort.by(order));
----
====
About the filter query: It is not possible to use a `CriteriaQuery` here, as this query would be converted into a Elasticsearch nested query which does not work in the filter context. So only `StringQuery` or `NativeQuery` can be used here. When using one of these, like the term query above, the Elasticsearch field names must be used, so take care, when these are redefined with the `@Field(name="...")` definition.
For the definition of the order path and the nested paths, the Java entity property names should be used.

View File

@ -9,6 +9,8 @@
* Improved AOT runtime hints for Elasticsearch client library classes.
* Add Kotlin extensions and repository coroutine support.
* Introducing `VersionConflictException` class thrown in case thatElasticsearch reports an 409 error with a version conflict.
* Enable MultiField annotation on property getter
* Support nested sort option
[[new-features.5-1-0]]
== New in Spring Data Elasticsearch 5.1

View File

@ -18,15 +18,7 @@ package org.springframework.data.elasticsearch.client.elc;
import static org.springframework.data.elasticsearch.client.elc.TypeUtils.*;
import static org.springframework.util.CollectionUtils.*;
import co.elastic.clients.elasticsearch._types.Conflicts;
import co.elastic.clients.elasticsearch._types.ExpandWildcard;
import co.elastic.clients.elasticsearch._types.FieldValue;
import co.elastic.clients.elasticsearch._types.InlineScript;
import co.elastic.clients.elasticsearch._types.OpType;
import co.elastic.clients.elasticsearch._types.SortOptions;
import co.elastic.clients.elasticsearch._types.SortOrder;
import co.elastic.clients.elasticsearch._types.VersionType;
import co.elastic.clients.elasticsearch._types.WaitForActiveShardOptions;
import co.elastic.clients.elasticsearch._types.*;
import co.elastic.clients.elasticsearch._types.mapping.FieldType;
import co.elastic.clients.elasticsearch._types.mapping.RuntimeField;
import co.elastic.clients.elasticsearch._types.mapping.RuntimeFieldType;
@ -71,6 +63,7 @@ import java.util.stream.Collectors;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.jetbrains.annotations.NotNull;
import org.springframework.dao.InvalidDataAccessApiUsageException;
import org.springframework.data.domain.Sort;
import org.springframework.data.elasticsearch.core.RefreshPolicy;
@ -89,6 +82,7 @@ import org.springframework.data.elasticsearch.core.mapping.ElasticsearchPersiste
import org.springframework.data.elasticsearch.core.mapping.ElasticsearchPersistentProperty;
import org.springframework.data.elasticsearch.core.mapping.IndexCoordinates;
import org.springframework.data.elasticsearch.core.query.*;
import org.springframework.data.elasticsearch.core.query.IndicesOptions;
import org.springframework.data.elasticsearch.core.reindex.ReindexRequest;
import org.springframework.data.elasticsearch.core.reindex.Remote;
import org.springframework.data.elasticsearch.core.script.Script;
@ -269,36 +263,7 @@ class RequestConverter {
List<Action> actions = new ArrayList<>();
aliasActions.getActions().forEach(aliasAction -> {
Action.Builder actionBuilder = new Action.Builder();
if (aliasAction instanceof AliasAction.Add add) {
AliasActionParameters parameters = add.getParameters();
actionBuilder.add(addActionBuilder -> {
addActionBuilder //
.indices(Arrays.asList(parameters.getIndices())) //
.isHidden(parameters.getHidden()) //
.isWriteIndex(parameters.getWriteIndex()) //
.routing(parameters.getRouting()) //
.indexRouting(parameters.getIndexRouting()) //
.searchRouting(parameters.getSearchRouting()); //
if (parameters.getAliases() != null) {
addActionBuilder.aliases(Arrays.asList(parameters.getAliases()));
}
Query filterQuery = parameters.getFilterQuery();
if (filterQuery != null) {
elasticsearchConverter.updateQuery(filterQuery, parameters.getFilterQueryClass());
co.elastic.clients.elasticsearch._types.query_dsl.Query esQuery = getQuery(filterQuery, null);
if (esQuery != null) {
addActionBuilder.filter(esQuery);
}
}
return addActionBuilder;
});
}
var actionBuilder = getBuilder(aliasAction);
if (aliasAction instanceof AliasAction.Remove remove) {
AliasActionParameters parameters = remove.getParameters();
@ -327,6 +292,40 @@ class RequestConverter {
return updateAliasRequestBuilder.build();
}
@NotNull
private Action.Builder getBuilder(AliasAction aliasAction) {
Action.Builder actionBuilder = new Action.Builder();
if (aliasAction instanceof AliasAction.Add add) {
AliasActionParameters parameters = add.getParameters();
actionBuilder.add(addActionBuilder -> {
addActionBuilder //
.indices(Arrays.asList(parameters.getIndices())) //
.isHidden(parameters.getHidden()) //
.isWriteIndex(parameters.getWriteIndex()) //
.routing(parameters.getRouting()) //
.indexRouting(parameters.getIndexRouting()) //
.searchRouting(parameters.getSearchRouting()); //
if (parameters.getAliases() != null) {
addActionBuilder.aliases(Arrays.asList(parameters.getAliases()));
}
Query filterQuery = parameters.getFilterQuery();
if (filterQuery != null) {
elasticsearchConverter.updateQuery(filterQuery, parameters.getFilterQueryClass());
co.elastic.clients.elasticsearch._types.query_dsl.Query esQuery = getQuery(filterQuery, null);
if (esQuery != null) {
addActionBuilder.filter(esQuery);
}
}
return addActionBuilder;
});
}
return actionBuilder;
}
public PutMappingRequest indicesPutMappingRequest(IndexCoordinates indexCoordinates, Document mapping) {
Assert.notNull(indexCoordinates, "indexCoordinates must not be null");
@ -1502,59 +1501,88 @@ class RequestConverter {
private SortOptions getSortOptions(Sort.Order order, @Nullable ElasticsearchPersistentEntity<?> persistentEntity) {
SortOrder sortOrder = order.getDirection().isDescending() ? SortOrder.Desc : SortOrder.Asc;
Order.Mode mode = Order.DEFAULT_MODE;
Order.Mode mode = order.getDirection().isAscending() ? Order.Mode.min : Order.Mode.max;
String unmappedType = null;
if (order instanceof Order o) {
mode = o.getMode();
unmappedType = o.getUnmappedType();
}
String missing = null;
NestedSortValue nestedSortValue = null;
if (SortOptions.Kind.Score.jsonValue().equals(order.getProperty())) {
return SortOptions.of(so -> so.score(s -> s.order(sortOrder)));
} else {
ElasticsearchPersistentProperty property = (persistentEntity != null) //
? persistentEntity.getPersistentProperty(order.getProperty()) //
: null;
String fieldName = property != null ? property.getFieldName() : order.getProperty();
Order.Mode finalMode = mode;
if (order instanceof GeoDistanceOrder geoDistanceOrder) {
return SortOptions.of(so -> so //
.geoDistance(gd -> gd //
.field(fieldName) //
.location(loc -> loc.latlon(Queries.latLon(geoDistanceOrder.getGeoPoint()))) //
.distanceType(geoDistanceType(geoDistanceOrder.getDistanceType())).mode(sortMode(finalMode)) //
.order(sortOrder(geoDistanceOrder.getDirection())) //
.unit(distanceUnit(geoDistanceOrder.getUnit())) //
.ignoreUnmapped(geoDistanceOrder.getIgnoreUnmapped())));
} else {
String missing = (order.getNullHandling() == Sort.NullHandling.NULLS_FIRST) ? "_first"
: ((order.getNullHandling() == Sort.NullHandling.NULLS_LAST) ? "_last" : null);
String finalUnmappedType = unmappedType;
return SortOptions.of(so -> so //
.field(f -> {
f.field(fieldName) //
.order(sortOrder) //
.mode(sortMode(finalMode));
if (finalUnmappedType != null) {
FieldType fieldType = fieldType(finalUnmappedType);
if (fieldType != null) {
f.unmappedType(fieldType);
}
}
if (missing != null) {
f.missing(fv -> fv //
.stringValue(missing));
}
return f;
}));
}
}
if (order instanceof Order o) {
if (o.getMode() != null) {
mode = o.getMode();
}
unmappedType = o.getUnmappedType();
missing = o.getMissing();
nestedSortValue = getNestedSort(o.getNested(), persistentEntity);
}
Order.Mode finalMode = mode;
String finalUnmappedType = unmappedType;
var finalNestedSortValue = nestedSortValue;
ElasticsearchPersistentProperty property = (persistentEntity != null) //
? persistentEntity.getPersistentProperty(order.getProperty()) //
: null;
String fieldName = property != null ? property.getFieldName() : order.getProperty();
if (order instanceof GeoDistanceOrder geoDistanceOrder) {
return getSortOptions(geoDistanceOrder, fieldName, finalMode);
}
var finalMissing = missing != null ? missing
: (order.getNullHandling() == Sort.NullHandling.NULLS_FIRST) ? "_first"
: ((order.getNullHandling() == Sort.NullHandling.NULLS_LAST) ? "_last" : null);
return SortOptions.of(so -> so //
.field(f -> {
f.field(fieldName) //
.order(sortOrder) //
.mode(sortMode(finalMode));
if (finalUnmappedType != null) {
FieldType fieldType = fieldType(finalUnmappedType);
if (fieldType != null) {
f.unmappedType(fieldType);
}
}
if (finalMissing != null) {
f.missing(fv -> fv //
.stringValue(finalMissing));
}
if (finalNestedSortValue != null) {
f.nested(finalNestedSortValue);
}
return f;
}));
}
@Nullable
private NestedSortValue getNestedSort(@Nullable Order.Nested nested,
ElasticsearchPersistentEntity<?> persistentEntity) {
return (nested == null) ? null
: NestedSortValue.of(b -> b //
.path(elasticsearchConverter.updateFieldNames(nested.getPath(), persistentEntity)) //
.maxChildren(nested.getMaxChildren()) //
.nested(getNestedSort(nested.getNested(), persistentEntity)) //
.filter(getQuery(nested.getFilter(), persistentEntity.getType())));
}
private static SortOptions getSortOptions(GeoDistanceOrder geoDistanceOrder, String fieldName, Order.Mode finalMode) {
return SortOptions.of(so -> so //
.geoDistance(gd -> gd //
.field(fieldName) //
.location(loc -> loc.latlon(Queries.latLon(geoDistanceOrder.getGeoPoint()))) //
.distanceType(geoDistanceType(geoDistanceOrder.getDistanceType())).mode(sortMode(finalMode)) //
.order(sortOrder(geoDistanceOrder.getDirection())) //
.unit(distanceUnit(geoDistanceOrder.getUnit())) //
.ignoreUnmapped(geoDistanceOrder.getIgnoreUnmapped())));
}
@SuppressWarnings("DuplicatedCode")

View File

@ -101,5 +101,15 @@ public interface ElasticsearchConverter
*/
void updateQuery(Query query, @Nullable Class<?> domainClass);
/**
* Replaces the parts in a dot separated property path with the field names of the respective properties. If no
* matching property is found, the original parts are rteturned.
*
* @param propertyPath the property path
* @param persistentEntity the replaced values.
* @return a String wihere the property names are replaced with field names
* @since 5.2
*/
public String updateFieldNames(String propertyPath, ElasticsearchPersistentEntity<?> persistentEntity);
// endregion
}

View File

@ -23,6 +23,7 @@ import java.util.stream.Collectors;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.jetbrains.annotations.NotNull;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.context.ApplicationContext;
@ -55,16 +56,7 @@ import org.springframework.data.mapping.Parameter;
import org.springframework.data.mapping.PersistentPropertyAccessor;
import org.springframework.data.mapping.SimplePropertyHandler;
import org.springframework.data.mapping.context.MappingContext;
import org.springframework.data.mapping.model.ConvertingPropertyAccessor;
import org.springframework.data.mapping.model.DefaultSpELExpressionEvaluator;
import org.springframework.data.mapping.model.EntityInstantiator;
import org.springframework.data.mapping.model.EntityInstantiators;
import org.springframework.data.mapping.model.ParameterValueProvider;
import org.springframework.data.mapping.model.PersistentEntityParameterValueProvider;
import org.springframework.data.mapping.model.PropertyValueProvider;
import org.springframework.data.mapping.model.SpELContext;
import org.springframework.data.mapping.model.SpELExpressionEvaluator;
import org.springframework.data.mapping.model.SpELExpressionParameterValueProvider;
import org.springframework.data.mapping.model.*;
import org.springframework.data.util.TypeInformation;
import org.springframework.format.datetime.DateFormatterRegistrar;
import org.springframework.lang.Nullable;
@ -1277,10 +1269,14 @@ public class MappingElasticsearchConverter
* @return an updated list of field names
*/
private List<String> updateFieldNames(List<String> fieldNames, ElasticsearchPersistentEntity<?> persistentEntity) {
return fieldNames.stream().map(fieldName -> {
ElasticsearchPersistentProperty persistentProperty = persistentEntity.getPersistentProperty(fieldName);
return persistentProperty != null ? persistentProperty.getFieldName() : fieldName;
}).collect(Collectors.toList());
return fieldNames.stream().map(fieldName -> updateFieldName(persistentEntity, fieldName))
.collect(Collectors.toList());
}
@NotNull
private String updateFieldName(ElasticsearchPersistentEntity<?> persistentEntity, String fieldName) {
ElasticsearchPersistentProperty persistentProperty = persistentEntity.getPersistentProperty(fieldName);
return persistentProperty != null ? persistentProperty.getFieldName() : fieldName;
}
private void updatePropertiesInCriteriaQuery(CriteriaQuery criteriaQuery, Class<?> domainClass) {
@ -1384,6 +1380,32 @@ public class MappingElasticsearchConverter
}
}
@Override
public String updateFieldNames(String propertyPath, ElasticsearchPersistentEntity<?> persistentEntity) {
Assert.notNull(propertyPath, "propertyPath must not be null");
Assert.notNull(persistentEntity, "persistentEntity must not be null");
var properties = propertyPath.split("\\.", 2);
if (properties.length > 0) {
var propertyName = properties[0];
var fieldName = updateFieldName(persistentEntity, propertyName);
if (properties.length > 1) {
var persistentProperty = persistentEntity.getPersistentProperty(propertyName);
return (persistentProperty != null)
? fieldName + "." + updateFieldNames(properties[1], mappingContext.getPersistentEntity(persistentProperty))
: fieldName;
} else {
return fieldName;
}
} else {
return propertyPath;
}
}
// endregion
static class MapValueAccessor {

View File

@ -37,7 +37,7 @@ public class GeoDistanceOrder extends Order {
private final Boolean ignoreUnmapped;
public GeoDistanceOrder(String property, GeoPoint geoPoint) {
this(property, geoPoint, Sort.Direction.ASC, DEFAULT_DISTANCE_TYPE, DEFAULT_MODE, DEFAULT_UNIT,
this(property, geoPoint, Sort.Direction.ASC, DEFAULT_DISTANCE_TYPE, null, DEFAULT_UNIT,
DEFAULT_IGNORE_UNMAPPED);
}

View File

@ -17,6 +17,10 @@ package org.springframework.data.elasticsearch.core.query;
import org.springframework.data.domain.Sort;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import java.util.function.BiFunction;
import java.util.function.Function;
/**
* Extends the {@link Sort.Order} with properties that can be set on Elasticsearch order options.
@ -26,46 +30,61 @@ import org.springframework.lang.Nullable;
*/
public class Order extends Sort.Order {
public static final Mode DEFAULT_MODE = Mode.min;
public static final Sort.NullHandling DEFAULT_NULL_HANDLING = Sort.NullHandling.NATIVE;
protected final Mode mode;
@Nullable protected final Mode mode;
@Nullable protected final String unmappedType;
@Nullable protected final String missing;
@Nullable protected final Nested nested;
public Order(Sort.Direction direction, String property) {
this(direction, property, DEFAULT_MODE, null);
this(direction, property, (Mode) null, null);
}
public Order(Sort.Direction direction, String property, Mode mode) {
public Order(Sort.Direction direction, String property, @Nullable Mode mode) {
this(direction, property, DEFAULT_NULL_HANDLING, mode, null);
}
public Order(Sort.Direction direction, String property, @Nullable String unmappedType) {
this(direction, property, DEFAULT_NULL_HANDLING, DEFAULT_MODE, unmappedType);
this(direction, property, DEFAULT_NULL_HANDLING, null, unmappedType);
}
public Order(Sort.Direction direction, String property, Mode mode, @Nullable String unmappedType) {
public Order(Sort.Direction direction, String property, @Nullable Mode mode, @Nullable String unmappedType) {
this(direction, property, DEFAULT_NULL_HANDLING, mode, unmappedType);
}
public Order(Sort.Direction direction, String property, Sort.NullHandling nullHandlingHint) {
this(direction, property, nullHandlingHint, DEFAULT_MODE, null);
this(direction, property, nullHandlingHint, null, null);
}
public Order(Sort.Direction direction, String property, Sort.NullHandling nullHandlingHint, Mode mode) {
public Order(Sort.Direction direction, String property, Sort.NullHandling nullHandlingHint, @Nullable Mode mode) {
this(direction, property, nullHandlingHint, mode, null);
}
public Order(Sort.Direction direction, String property, Sort.NullHandling nullHandlingHint,
@Nullable String unmappedType) {
this(direction, property, nullHandlingHint, DEFAULT_MODE, unmappedType);
this(direction, property, nullHandlingHint, null, unmappedType);
}
public Order(Sort.Direction direction, String property, Sort.NullHandling nullHandlingHint, Mode mode,
public Order(Sort.Direction direction, String property, Sort.NullHandling nullHandlingHint, @Nullable Mode mode,
@Nullable String unmappedType) {
this(direction, property, nullHandlingHint, null, unmappedType, null);
}
public Order(Sort.Direction direction, String property, Sort.NullHandling nullHandlingHint, @Nullable Mode mode,
@Nullable String unmappedType, @Nullable String missing) {
this(direction, property, nullHandlingHint, mode, unmappedType, missing, null);
}
public Order(Sort.Direction direction, String property, Sort.NullHandling nullHandlingHint, @Nullable Mode mode,
@Nullable String unmappedType, @Nullable String missing, @Nullable Nested nested) {
super(direction, property, nullHandlingHint);
this.mode = mode;
this.unmappedType = unmappedType;
this.missing = missing;
this.nested = nested;
}
@Nullable
@ -75,33 +94,143 @@ public class Order extends Sort.Order {
@Override
public Sort.Order with(Sort.Direction direction) {
return new Order(direction, getProperty(), getNullHandling(), mode, unmappedType);
return new Order(direction, getProperty(), getNullHandling(), mode, unmappedType, missing, nested);
}
@Override
public Sort.Order withProperty(String property) {
return new Order(getDirection(), property, getNullHandling(), mode, unmappedType);
return new Order(getDirection(), property, getNullHandling(), mode, unmappedType, missing, nested);
}
@Override
public Sort.Order with(Sort.NullHandling nullHandling) {
return new Order(getDirection(), getProperty(), nullHandling, getMode(), unmappedType);
return new Order(getDirection(), getProperty(), nullHandling, getMode(), unmappedType, missing, nested);
}
public Order withUnmappedType(@Nullable String unmappedType) {
return new Order(getDirection(), getProperty(), getNullHandling(), getMode(), unmappedType);
return new Order(getDirection(), getProperty(), getNullHandling(), getMode(), unmappedType, missing, nested);
}
public Order with(Mode mode) {
return new Order(getDirection(), getProperty(), getNullHandling(), mode, unmappedType);
public Order with(@Nullable Mode mode) {
return new Order(getDirection(), getProperty(), getNullHandling(), mode, unmappedType, missing, nested);
}
public Order withMissing(@Nullable String missing) {
return new Order(getDirection(), getProperty(), getNullHandling(), mode, unmappedType, missing, nested);
}
public Order withNested(@Nullable Nested nested) {
return new Order(getDirection(), getProperty(), getNullHandling(), mode, unmappedType, missing, nested);
}
@Nullable
public Mode getMode() {
return mode;
}
@Nullable
public String getMissing() {
return missing;
}
@Nullable
public Nested getNested() {
return nested;
}
public enum Mode {
min, max, median, avg
}
public static class Nested {
private String path;
@Nullable private Query filter;
@Nullable private Integer maxChildren = null;
@Nullable private Nested nested;
public static Nested of(String path, Function<Nested.Builder, Nested.Builder> builderFunction) {
Assert.notNull(path, "path must not be null");
Assert.notNull(builderFunction, "builderFunction must not be null");
return builderFunction.apply(builder(path)).build();
}
public Nested(String path, @Nullable Query filter, @Nullable Integer maxChildren, @Nullable Nested nested) {
Assert.notNull(path, "path must not be null");
this.path = path;
this.filter = filter;
this.maxChildren = maxChildren;
this.nested = nested;
}
public String getPath() {
return path;
}
@Nullable
public Query getFilter() {
return filter;
}
@Nullable
public Integer getMaxChildren() {
return maxChildren;
}
@Nullable
public Nested getNested() {
return nested;
}
public static Builder builder(String path) {
return new Builder(path);
}
public static class Builder {
private String path;
@Nullable private Query filter = null;
@Nullable private Integer maxChildren = null;
@Nullable private Nested nested = null;
public Builder(String path) {
Assert.notNull(path, "path must not be null");
this.path = path;
}
/**
* Sets the filter query for a nested sort.<br/>
* Note: This cannot be a {@link CriteriaQuery}, as that would be sent as a nested query within the filter,
* use a {@link org.springframework.data.elasticsearch.client.elc.NativeQuery} or {@link StringQuery} instead.
* @param filter the filter to set
* @return this builder
* @throws IllegalArgumentException when a {@link CriteriaQuery} is passed.
*/
public Builder withFilter(@Nullable Query filter) {
if (filter instanceof CriteriaQuery) {
throw new IllegalArgumentException("Cannot use a CriteriaQuery in a nested sort filter.");
}
this.filter = filter;
return this;
}
public Builder withMaxChildren(@Nullable Integer maxChildren) {
this.maxChildren = maxChildren;
return this;
}
public Builder withNested(@Nullable Nested nested) {
this.nested = nested;
return this;
}
public Nested build() {
return new Nested(path, filter, maxChildren, nested);
}
}
}
}

View File

@ -25,16 +25,7 @@ import java.time.LocalTime;
import java.time.OffsetTime;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.*;
import java.util.stream.Collectors;
import org.intellij.lang.annotations.Language;
@ -69,6 +60,7 @@ import org.springframework.data.elasticsearch.core.geo.GeoJsonMultiPolygon;
import org.springframework.data.elasticsearch.core.geo.GeoJsonPoint;
import org.springframework.data.elasticsearch.core.geo.GeoJsonPolygon;
import org.springframework.data.elasticsearch.core.geo.GeoPoint;
import org.springframework.data.elasticsearch.core.mapping.ElasticsearchPersistentEntity;
import org.springframework.data.elasticsearch.core.mapping.PropertyValueConverter;
import org.springframework.data.elasticsearch.core.mapping.SimpleElasticsearchMappingContext;
import org.springframework.data.elasticsearch.core.query.Criteria;
@ -94,7 +86,13 @@ import org.springframework.util.Assert;
*/
public class MappingElasticsearchConverterUnitTests {
static final String JSON_STRING = "{\"_class\":\"org.springframework.data.elasticsearch.core.convert.MappingElasticsearchConverterUnitTests$Car\",\"name\":\"Grat\",\"model\":\"Ford\"}";
static final String JSON_STRING = """
{
"_class": "org.springframework.data.elasticsearch.core.convert.MappingElasticsearchConverterUnitTests$Car",
"name": "Grat",
"model": "Ford"
}
""";
static final String CAR_MODEL = "Ford";
static final String CAR_NAME = "Grat";
MappingElasticsearchConverter mappingElasticsearchConverter;
@ -233,18 +231,15 @@ public class MappingElasticsearchConverterUnitTests {
@Test
public void shouldFailToInitializeGivenMappingContextIsNull() {
// given
assertThatThrownBy(() -> new MappingElasticsearchConverter(null)).isInstanceOf(IllegalArgumentException.class);
}
@Test
public void shouldReturnMappingContextWithWhichItWasInitialized() {
// given
SimpleElasticsearchMappingContext mappingContext = new SimpleElasticsearchMappingContext();
MappingElasticsearchConverter converter = new MappingElasticsearchConverter(mappingContext);
// then
assertThat(converter.getMappingContext()).isNotNull();
assertThat(converter.getMappingContext()).isSameAs(mappingContext);
}
@ -252,35 +247,29 @@ public class MappingElasticsearchConverterUnitTests {
@Test
public void shouldReturnDefaultConversionService() {
// given
MappingElasticsearchConverter converter = new MappingElasticsearchConverter(
new SimpleElasticsearchMappingContext());
// when
ConversionService conversionService = converter.getConversionService();
// then
assertThat(conversionService).isNotNull();
}
@Test // DATAES-530
public void shouldMapObjectToJsonString() {
public void shouldMapObjectToJsonString() throws JSONException {
Car car = new Car();
car.setModel(CAR_MODEL);
car.setName(CAR_NAME);
String jsonResult = mappingElasticsearchConverter.mapObject(car).toJson();
assertThat(jsonResult).isEqualTo(JSON_STRING);
assertEquals(jsonResult, JSON_STRING, false);
}
@Test // DATAES-530
public void shouldReadJsonStringToObject() {
// Given
// When
Car result = mappingElasticsearchConverter.read(Car.class, Document.parse(JSON_STRING));
// Then
assertThat(result).isNotNull();
assertThat(result.getName()).isEqualTo(CAR_NAME);
assertThat(result.getModel()).isEqualTo(CAR_MODEL);
@ -288,7 +277,6 @@ public class MappingElasticsearchConverterUnitTests {
@Test // DATAES-530
public void shouldMapGeoPointElasticsearchNames() throws JSONException {
// given
double lon = 5;
double lat = 48;
Point point = new Point(lon, lat);
@ -327,17 +315,14 @@ public class MappingElasticsearchConverterUnitTests {
@Test // DATAES-530
public void ignoresReadOnlyProperties() {
// given
Sample sample = new Sample();
sample.setReadOnly("readOnly");
sample.setProperty("property");
sample.setJavaTransientProperty("javaTransient");
sample.setAnnotatedTransientProperty("transient");
// when
String result = mappingElasticsearchConverter.mapObject(sample).toJson();
// then
assertThat(result).contains("\"property\"");
assertThat(result).contains("\"javaTransient\"");
@ -638,13 +623,15 @@ public class MappingElasticsearchConverterUnitTests {
person.birthDate = LocalDate.of(2000, 8, 22);
person.gender = Gender.MAN;
String expected = '{' + //
" \"id\": \"4711\"," + //
" \"first-name\": \"John\"," + //
" \"last-name\": \"Doe\"," + //
" \"birth-date\": \"22.08.2000\"," + //
" \"gender\": \"MAN\"" + //
'}';
String expected = """
{
"id": "4711",
"first-name": "John",
"last-name": "Doe",
"birth-date": "22.08.2000",
"gender": "MAN"
}
""";
Document document = Document.create();
mappingElasticsearchConverter.write(person, document);
String json = document.toJson();
@ -961,30 +948,59 @@ public class MappingElasticsearchConverterUnitTests {
@Nested
class RangeTests {
static final String JSON = "{"
+ "\"_class\":\"org.springframework.data.elasticsearch.core.convert.MappingElasticsearchConverterUnitTests$RangeTests$RangeEntity\","
+ "\"integerRange\":{\"gt\":\"1\",\"lt\":\"10\"}," //
+ "\"floatRange\":{\"gte\":\"1.2\",\"lte\":\"2.5\"}," //
+ "\"longRange\":{\"gt\":\"2\",\"lte\":\"5\"}," //
+ "\"doubleRange\":{\"gte\":\"3.2\",\"lt\":\"7.4\"}," //
+ "\"dateRange\":{\"gte\":\"1970-01-01T00:00:00.000Z\",\"lte\":\"1970-01-01T01:00:00.000Z\"}," //
+ "\"localDateRange\":{\"gte\":\"2021-07-06\"}," //
+ "\"localTimeRange\":{\"gte\":\"00:30:00.000\",\"lt\":\"02:30:00.000\"}," //
+ "\"localDateTimeRange\":{\"gt\":\"2021-01-01T00:30:00.000\",\"lt\":\"2021-01-01T02:30:00.000\"}," //
+ "\"offsetTimeRange\":{\"gte\":\"00:30:00.000+02:00\",\"lt\":\"02:30:00.000+02:00\"}," //
+ "\"zonedDateTimeRange\":{\"gte\":\"2021-01-01T00:30:00.000+02:00\",\"lte\":\"2021-01-01T00:30:00.000+02:00\"}," //
+ "\"nullRange\":null}";
static final String JSON = """
{
"_class": "org.springframework.data.elasticsearch.core.convert.MappingElasticsearchConverterUnitTests$RangeTests$RangeEntity",
"integerRange": {
"gt": "1",
"lt": "10"
},
"floatRange": {
"gte": "1.2",
"lte": "2.5"
},
"longRange": {
"gt": "2",
"lte": "5"
},
"doubleRange": {
"gte": "3.2",
"lt": "7.4"
},
"dateRange": {
"gte": "1970-01-01T00:00:00.000Z",
"lte": "1970-01-01T01:00:00.000Z"
},
"localDateRange": {
"gte": "2021-07-06"
},
"localTimeRange": {
"gte": "00:30:00.000",
"lt": "02:30:00.000"
},
"localDateTimeRange": {
"gt": "2021-01-01T00:30:00.000",
"lt": "2021-01-01T02:30:00.000"
},
"offsetTimeRange": {
"gte": "00:30:00.000+02:00",
"lt": "02:30:00.000+02:00"
},
"zonedDateTimeRange": {
"gte": "2021-01-01T00:30:00.000+02:00",
"lte": "2021-01-01T00:30:00.000+02:00"
},
"nullRange": null
}
""";
@Test
public void shouldReadRanges() throws JSONException {
// given
Document source = Document.parse(JSON);
// when
RangeEntity entity = mappingElasticsearchConverter.read(RangeEntity.class, source);
// then
assertThat(entity) //
.isNotNull() //
.satisfies(e -> {
@ -1010,7 +1026,6 @@ public class MappingElasticsearchConverterUnitTests {
@Test
public void shouldWriteRanges() throws JSONException {
// given
Document source = Document.parse(JSON);
RangeEntity entity = new RangeEntity();
entity.setIntegerRange(Range.open(1, 10));
@ -1028,10 +1043,8 @@ public class MappingElasticsearchConverterUnitTests {
Range.just(ZonedDateTime.of(LocalDate.of(2021, 1, 1), LocalTime.of(0, 30), ZoneOffset.ofHours(2))));
entity.setNullRange(null);
// when
Document document = mappingElasticsearchConverter.mapObject(entity);
// then
assertThat(document).isEqualTo(source);
}
@ -1564,7 +1577,6 @@ public class MappingElasticsearchConverterUnitTests {
Document source = Document.parse(json);
// when
EntityWithCustomValueConverters entity = mappingElasticsearchConverter.read(EntityWithCustomValueConverters.class,
source);
@ -2038,6 +2050,17 @@ public class MappingElasticsearchConverterUnitTests {
assertThat(entity.getDottedField()).isEqualTo("dotted field");
}
@Test // #1784
@DisplayName("should map property path to field names")
void shouldMapPropertyPathToFieldNames() {
var propertyPath = "level1Entries.level2Entries.keyWord";
ElasticsearchPersistentEntity<?> persistentEntity = mappingElasticsearchConverter.getMappingContext().getPersistentEntity(NestedEntity.class);
var mappedNames = mappingElasticsearchConverter.updateFieldNames(propertyPath, persistentEntity);
assertThat(mappedNames).isEqualTo("level-one.level-two.key-word");
}
// region entities
public static class Sample {
@Nullable public @ReadOnlyProperty String readOnly;
@ -2288,149 +2311,20 @@ public class MappingElasticsearchConverterUnitTests {
}
interface Inventory {
String getLabel();
String label();
}
static class Gun implements Inventory {
final String label;
final int shotsPerMagazine;
public Gun(@Nullable String label, int shotsPerMagazine) {
this.label = label;
this.shotsPerMagazine = shotsPerMagazine;
}
@Override
public String getLabel() {
return label;
}
public int getShotsPerMagazine() {
return shotsPerMagazine;
}
@Override
public boolean equals(Object o) {
if (this == o)
return true;
if (o == null || getClass() != o.getClass())
return false;
Gun gun = (Gun) o;
if (shotsPerMagazine != gun.shotsPerMagazine)
return false;
return label.equals(gun.label);
}
@Override
public int hashCode() {
int result = label.hashCode();
result = 31 * result + shotsPerMagazine;
return result;
}
record Gun(String label, int shotsPerMagazine) implements Inventory {
}
static class Grenade implements Inventory {
final String label;
public Grenade(String label) {
this.label = label;
}
@Override
public String getLabel() {
return label;
}
@Override
public boolean equals(Object o) {
if (this == o)
return true;
if (!(o instanceof Grenade grenade))
return false;
return label.equals(grenade.label);
}
@Override
public int hashCode() {
return label.hashCode();
}
record Grenade(String label) implements Inventory {
}
@TypeAlias("rifle")
static class Rifle implements Inventory {
final String label;
final double weight;
final int maxShotsPerMagazine;
public Rifle(String label, double weight, int maxShotsPerMagazine) {
this.label = label;
this.weight = weight;
this.maxShotsPerMagazine = maxShotsPerMagazine;
}
@Override
public String getLabel() {
return label;
}
@Override
public boolean equals(Object o) {
if (this == o)
return true;
if (!(o instanceof Rifle rifle))
return false;
if (Double.compare(rifle.weight, weight) != 0)
return false;
if (maxShotsPerMagazine != rifle.maxShotsPerMagazine)
return false;
return label.equals(rifle.label);
}
@Override
public int hashCode() {
int result;
long temp;
result = label.hashCode();
temp = Double.doubleToLongBits(weight);
result = 31 * result + (int) (temp ^ (temp >>> 32));
result = 31 * result + maxShotsPerMagazine;
return result;
}
record Rifle(String label, double weight, int maxShotsPerMagazine) implements Inventory {
}
static class ShotGun implements Inventory {
private final String label;
public ShotGun(String label) {
this.label = label;
}
@Override
public String getLabel() {
return label;
}
@Override
public boolean equals(Object o) {
if (this == o)
return true;
if (!(o instanceof ShotGun shotGun))
return false;
return label.equals(shotGun.label);
}
@Override
public int hashCode() {
return label.hashCode();
}
record ShotGun(String label) implements Inventory {
}
static class Address {
@ -2599,7 +2493,7 @@ public class MappingElasticsearchConverterUnitTests {
public Map<String, Object> convert(ShotGun source) {
LinkedHashMap<String, Object> target = new LinkedHashMap<>();
target.put("model", source.getLabel());
target.put("model", source.label());
target.put("_class", ShotGun.class.getName());
return target;
}
@ -3267,6 +3161,54 @@ public class MappingElasticsearchConverterUnitTests {
}
}
static class NestedEntity {
@Id
@Nullable private String id;
@Field(type = FieldType.Nested, name = "level-one") private List<Level1> level1Entries;
@Nullable
public String getId() {
return id;
}
public void setId(@Nullable String id) {
this.id = id;
}
public List<Level1> getLevel1Entries() {
return level1Entries;
}
public void setLevel1Entries(List<Level1> level1Entries) {
this.level1Entries = level1Entries;
}
static class Level1 {
@Field(type = FieldType.Nested, name = "level-two") private List<Level2> level2Entries;
public List<Level2> getLevel2Entries() {
return level2Entries;
}
public void setLevel2Entries(List<Level2> level2Entries) {
this.level2Entries = level2Entries;
}
}
static class Level2 {
@Field(type = FieldType.Keyword, name = "key-word") private String keyWord;
public String getKeyWord() {
return keyWord;
}
public void setKeyWord(String keyWord) {
this.keyWord = keyWord;
}
}
}
// endregion
private static String reverse(Object o) {

View File

@ -0,0 +1,39 @@
/*
* Copyright 023 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.sort;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.data.elasticsearch.junit.jupiter.ElasticsearchTemplateConfiguration;
import org.springframework.data.elasticsearch.utils.IndexNameProvider;
import org.springframework.test.context.ContextConfiguration;
/**
* @author Peter-Josef Meisch
*/
@ContextConfiguration(classes = { NestedSortELCIntegrationTests.Config.class })
public class NestedSortELCIntegrationTests extends NestedSortIntegrationTests {
@Configuration
@Import({ ElasticsearchTemplateConfiguration.class })
static class Config {
@Bean
IndexNameProvider indexNameProvider() {
return new IndexNameProvider("nested-sort");
}
}
}

View File

@ -0,0 +1,200 @@
/*
* Copyright 2023 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.sort;
import static org.assertj.core.api.Assertions.*;
import java.util.List;
import java.util.function.Function;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.annotation.Id;
import org.springframework.data.domain.Sort;
import org.springframework.data.elasticsearch.annotations.Document;
import org.springframework.data.elasticsearch.annotations.Field;
import org.springframework.data.elasticsearch.annotations.FieldType;
import org.springframework.data.elasticsearch.core.ElasticsearchOperations;
import org.springframework.data.elasticsearch.core.IndexOperations;
import org.springframework.data.elasticsearch.core.mapping.IndexCoordinates;
import org.springframework.data.elasticsearch.core.query.Order.Nested;
import org.springframework.data.elasticsearch.core.query.Query;
import org.springframework.data.elasticsearch.core.query.StringQuery;
import org.springframework.data.elasticsearch.junit.jupiter.SpringIntegrationTest;
import org.springframework.data.elasticsearch.utils.IndexNameProvider;
import org.springframework.lang.Nullable;
/**
* Integration tests for nested sorts.
*
* @author Peter-Josef Meisch
*/
@SpringIntegrationTest
public abstract class NestedSortIntegrationTests {
@Autowired ElasticsearchOperations operations;
@Autowired IndexNameProvider indexNameProvider;
@Nullable IndexOperations indexOperations;
private final Actor marlonBrando = new Actor("Marlon Brando", "m", 1924);
private final Actor robertDuvall = new Actor("RobertDuvall", "m", 1931);
private final Actor jackNicholson = new Actor("Jack Nicholson", "m", 1937);
private final Actor alPacino = new Actor("Al Pacino", "m", 1940);
private final Actor ronalLeeErmey = new Actor("Ronal Lee Ermey", "m", 1944);
private final Actor dianeKeaton = new Actor("Diane Keaton", "f", 1946);
private final Actor shelleyDuval = new Actor("Shelley Duval", "f", 1949);
private final Actor matthewModine = new Actor("Matthew Modine", "m", 1959);
private final Movie theGodfather = new Movie("The Godfather", 1972, List.of(alPacino, dianeKeaton));
private final Movie apocalypseNow = new Movie("Apocalypse Now", 1979, List.of(marlonBrando, robertDuvall));
private final Movie theShining = new Movie("The Shining", 1980, List.of(jackNicholson, shelleyDuval));
private final Movie fullMetalJacket = new Movie("Full Metal Jacket", 1987, List.of(matthewModine, ronalLeeErmey));
private final Director stanleyKubrik = new Director("1", "Stanley Kubrik", 1928,
List.of(fullMetalJacket, theShining));
private final Director francisFordCoppola = new Director("2", "Francis Ford Coppola", 1939,
List.of(apocalypseNow, theGodfather));
@BeforeEach
void setUp() {
indexNameProvider.increment();
indexOperations = operations.indexOps(Director.class);
indexOperations.createWithMapping();
operations.save(francisFordCoppola, stanleyKubrik);
}
@Test
@Order(Integer.MAX_VALUE)
void cleanup() {
operations.indexOps(IndexCoordinates.of(indexNameProvider.getPrefix() + '*')).delete();
}
@Test // #1784
@DisplayName("should sort directors by year of birth of actor in their movies ascending")
void shouldSortDirectorsByYearOfBirthOfActorInTheirMoviesAscending() {
var order = new org.springframework.data.elasticsearch.core.query.Order(Sort.Direction.ASC,
"movies.actors.yearOfBirth") //
.withNested(Nested.of("movies", //
b -> b.withNested(Nested.of("movies.actors", Function.identity()))));
var query = Query.findAll().addSort(Sort.by(order));
var searchHits = operations.search(query, Director.class);
assertThat(searchHits.getTotalHits()).isEqualTo(2);
assertThat(searchHits.getSearchHit(0).getContent().id).isEqualTo(francisFordCoppola.id);
var sortValues = searchHits.getSearchHit(0).getSortValues();
assertThat(sortValues).hasSize(1);
assertThat(sortValues.get(0)).isEqualTo("1924");
assertThat(searchHits.getSearchHit(1).getContent().id).isEqualTo(stanleyKubrik.id);
sortValues = searchHits.getSearchHit(1).getSortValues();
assertThat(sortValues).hasSize(1);
assertThat(sortValues.get(0)).isEqualTo("1937");
}
@Test // #1784
@DisplayName("should sort directors by year of birth of actor in their movies descending")
void shouldSortDirectorsByYearOfBirthOfActorInTheirMoviesDescending() {
var order = new org.springframework.data.elasticsearch.core.query.Order(Sort.Direction.DESC,
"movies.actors.yearOfBirth") //
.withNested( //
Nested.builder("movies") //
.withNested(Nested.builder("movies.actors") //
.build()) //
.build());
var query = Query.findAll().addSort(Sort.by(order));
var searchHits = operations.search(query, Director.class);
assertThat(searchHits.getTotalHits()).isEqualTo(2);
assertThat(searchHits.getSearchHit(0).getContent().id).isEqualTo(stanleyKubrik.id);
var sortValues = searchHits.getSearchHit(0).getSortValues();
assertThat(sortValues).hasSize(1);
assertThat(sortValues.get(0)).isEqualTo("1959");
assertThat(searchHits.getSearchHit(1).getContent().id).isEqualTo(francisFordCoppola.id);
sortValues = searchHits.getSearchHit(1).getSortValues();
assertThat(sortValues).hasSize(1);
assertThat(sortValues.get(0)).isEqualTo("1946");
}
@Test // #1784
@DisplayName("should sort directors by year of birth of male actor in their movies descending")
void shouldSortDirectorsByYearOfBirthOfMaleActorInTheirMoviesDescending() {
var filter = StringQuery.builder("""
{ "term": {"movies.actors.sex": "m"} }
""").build();
var order = new org.springframework.data.elasticsearch.core.query.Order(Sort.Direction.DESC,
"movies.actors.yearOfBirth") //
.withNested( //
Nested.builder("movies") //
.withNested( //
Nested.builder("movies.actors") //
.withFilter(filter) //
.build()) //
.build());
var query = Query.findAll().addSort(Sort.by(order));
var searchHits = operations.search(query, Director.class);
assertThat(searchHits.getTotalHits()).isEqualTo(2);
assertThat(searchHits.getSearchHit(0).getContent().id).isEqualTo(stanleyKubrik.id);
var sortValues = searchHits.getSearchHit(0).getSortValues();
assertThat(sortValues).hasSize(1);
assertThat(sortValues.get(0)).isEqualTo("1959");
assertThat(searchHits.getSearchHit(1).getContent().id).isEqualTo(francisFordCoppola.id);
sortValues = searchHits.getSearchHit(1).getSortValues();
assertThat(sortValues).hasSize(1);
assertThat(sortValues.get(0)).isEqualTo("1940");
}
@Document(indexName = "#{@indexNameProvider.indexName()}")
record Director( //
@Nullable @Id String id, //
@Field(type = FieldType.Text) String name, //
@Field(type = FieldType.Integer) Integer yearOfBirth, //
@Field(type = FieldType.Nested) List<Movie> movies //
) {
}
record Movie( //
@Field(type = FieldType.Text) String title, //
@Field(type = FieldType.Integer) Integer year, //
@Field(type = FieldType.Nested) List<Actor> actors //
) {
}
record Actor( //
@Field(type = FieldType.Text) String name, //
@Field(type = FieldType.Keyword) String sex, //
@Field(type = FieldType.Integer) Integer yearOfBirth //
) {
}
}