From bd71a9311ca3c7ab98a8fc63864817fb534bc960 Mon Sep 17 00:00:00 2001 From: Peter-Josef Meisch Date: Sun, 30 Jul 2023 16:29:52 +0000 Subject: [PATCH] Add support for nested sort. Original Pull Request #2653 Closes #1783 --- .../reference/elasticsearch-misc.adoc | 31 ++ .../asciidoc/reference/elasticsearch-new.adoc | 2 + .../client/elc/RequestConverter.java | 202 ++++++----- .../core/convert/ElasticsearchConverter.java | 10 + .../MappingElasticsearchConverter.java | 50 ++- .../core/query/GeoDistanceOrder.java | 2 +- .../data/elasticsearch/core/query/Order.java | 161 ++++++++- ...appingElasticsearchConverterUnitTests.java | 318 +++++++----------- .../sort/NestedSortELCIntegrationTests.java | 39 +++ .../sort/NestedSortIntegrationTests.java | 200 +++++++++++ 10 files changed, 709 insertions(+), 306 deletions(-) create mode 100644 src/test/java/org/springframework/data/elasticsearch/core/query/sort/NestedSortELCIntegrationTests.java create mode 100644 src/test/java/org/springframework/data/elasticsearch/core/query/sort/NestedSortIntegrationTests.java diff --git a/src/main/asciidoc/reference/elasticsearch-misc.adoc b/src/main/asciidoc/reference/elasticsearch-misc.adoc index 25bc8c5e7..c865ba533 100644 --- a/src/main/asciidoc/reference/elasticsearch-misc.adoc +++ b/src/main/asciidoc/reference/elasticsearch-misc.adoc @@ -419,3 +419,34 @@ public class PersonCustomRepositoryImpl implements PersonCustomRepository { <.> The parameters are passed in a `Map` <.> 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. diff --git a/src/main/asciidoc/reference/elasticsearch-new.adoc b/src/main/asciidoc/reference/elasticsearch-new.adoc index 332cd4528..3ebe684f1 100644 --- a/src/main/asciidoc/reference/elasticsearch-new.adoc +++ b/src/main/asciidoc/reference/elasticsearch-new.adoc @@ -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 diff --git a/src/main/java/org/springframework/data/elasticsearch/client/elc/RequestConverter.java b/src/main/java/org/springframework/data/elasticsearch/client/elc/RequestConverter.java index 810659812..8dbaf3b0a 100644 --- a/src/main/java/org/springframework/data/elasticsearch/client/elc/RequestConverter.java +++ b/src/main/java/org/springframework/data/elasticsearch/client/elc/RequestConverter.java @@ -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 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") diff --git a/src/main/java/org/springframework/data/elasticsearch/core/convert/ElasticsearchConverter.java b/src/main/java/org/springframework/data/elasticsearch/core/convert/ElasticsearchConverter.java index 0fa49f035..9aefc4e6f 100644 --- a/src/main/java/org/springframework/data/elasticsearch/core/convert/ElasticsearchConverter.java +++ b/src/main/java/org/springframework/data/elasticsearch/core/convert/ElasticsearchConverter.java @@ -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 } diff --git a/src/main/java/org/springframework/data/elasticsearch/core/convert/MappingElasticsearchConverter.java b/src/main/java/org/springframework/data/elasticsearch/core/convert/MappingElasticsearchConverter.java index 64365508a..70776f67c 100644 --- a/src/main/java/org/springframework/data/elasticsearch/core/convert/MappingElasticsearchConverter.java +++ b/src/main/java/org/springframework/data/elasticsearch/core/convert/MappingElasticsearchConverter.java @@ -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 updateFieldNames(List 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 { 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 index 2c06c7ad9..508a9bc7b 100644 --- a/src/main/java/org/springframework/data/elasticsearch/core/query/GeoDistanceOrder.java +++ b/src/main/java/org/springframework/data/elasticsearch/core/query/GeoDistanceOrder.java @@ -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); } diff --git a/src/main/java/org/springframework/data/elasticsearch/core/query/Order.java b/src/main/java/org/springframework/data/elasticsearch/core/query/Order.java index 6c5fbdff5..f608f5a27 100644 --- a/src/main/java/org/springframework/data/elasticsearch/core/query/Order.java +++ b/src/main/java/org/springframework/data/elasticsearch/core/query/Order.java @@ -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 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.
+ * 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); + } + } + } } diff --git a/src/test/java/org/springframework/data/elasticsearch/core/convert/MappingElasticsearchConverterUnitTests.java b/src/test/java/org/springframework/data/elasticsearch/core/convert/MappingElasticsearchConverterUnitTests.java index 89c5222d6..b0c7fe07c 100644 --- a/src/test/java/org/springframework/data/elasticsearch/core/convert/MappingElasticsearchConverterUnitTests.java +++ b/src/test/java/org/springframework/data/elasticsearch/core/convert/MappingElasticsearchConverterUnitTests.java @@ -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 convert(ShotGun source) { LinkedHashMap 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 level1Entries; + + @Nullable + public String getId() { + return id; + } + + public void setId(@Nullable String id) { + this.id = id; + } + + public List getLevel1Entries() { + return level1Entries; + } + + public void setLevel1Entries(List level1Entries) { + this.level1Entries = level1Entries; + } + + static class Level1 { + @Field(type = FieldType.Nested, name = "level-two") private List level2Entries; + + public List getLevel2Entries() { + return level2Entries; + } + + public void setLevel2Entries(List 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) { diff --git a/src/test/java/org/springframework/data/elasticsearch/core/query/sort/NestedSortELCIntegrationTests.java b/src/test/java/org/springframework/data/elasticsearch/core/query/sort/NestedSortELCIntegrationTests.java new file mode 100644 index 000000000..31615b3ff --- /dev/null +++ b/src/test/java/org/springframework/data/elasticsearch/core/query/sort/NestedSortELCIntegrationTests.java @@ -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"); + } + } +} diff --git a/src/test/java/org/springframework/data/elasticsearch/core/query/sort/NestedSortIntegrationTests.java b/src/test/java/org/springframework/data/elasticsearch/core/query/sort/NestedSortIntegrationTests.java new file mode 100644 index 000000000..dbb90fc59 --- /dev/null +++ b/src/test/java/org/springframework/data/elasticsearch/core/query/sort/NestedSortIntegrationTests.java @@ -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 movies // + ) { + } + + record Movie( // + @Field(type = FieldType.Text) String title, // + @Field(type = FieldType.Integer) Integer year, // + @Field(type = FieldType.Nested) List actors // + ) { + } + + record Actor( // + @Field(type = FieldType.Text) String name, // + @Field(type = FieldType.Keyword) String sex, // + @Field(type = FieldType.Integer) Integer yearOfBirth // + ) { + } + +}