From caebe08cf2592351336e94ae475f31db3314b44a Mon Sep 17 00:00:00 2001 From: Peter-Josef Meisch Date: Wed, 3 Jun 2020 14:25:48 +0200 Subject: [PATCH] DATAES-263 - Inner Hits support. (#473) original PR: #473 --- .../reference/elasticsearch-operations.adoc | 1 + .../core/AbstractElasticsearchTemplate.java | 5 +- .../core/ReactiveElasticsearchTemplate.java | 2 +- .../data/elasticsearch/core/SearchHit.java | 52 +++++ .../elasticsearch/core/SearchHitMapping.java | 181 +++++++++++++++--- .../core/document/DocumentAdapters.java | 55 +++++- .../core/document/NestedMetaData.java | 53 +++++ .../core/document/SearchDocument.java | 21 +- .../core/document/SearchDocumentResponse.java | 38 +++- .../elasticsearch/core/InnerHitsTests.java | 170 ++++++++++++++++ .../core/InnerHitsTransportTests.java | 31 +++ 11 files changed, 568 insertions(+), 41 deletions(-) create mode 100644 src/main/java/org/springframework/data/elasticsearch/core/document/NestedMetaData.java create mode 100644 src/test/java/org/springframework/data/elasticsearch/core/InnerHitsTests.java create mode 100644 src/test/java/org/springframework/data/elasticsearch/core/InnerHitsTransportTests.java diff --git a/src/main/asciidoc/reference/elasticsearch-operations.adoc b/src/main/asciidoc/reference/elasticsearch-operations.adoc index b8a69193..69850323 100644 --- a/src/main/asciidoc/reference/elasticsearch-operations.adoc +++ b/src/main/asciidoc/reference/elasticsearch-operations.adoc @@ -134,6 +134,7 @@ Contains the following information: * Score * Sort Values * Highlight fields +* Inner hits (this is an embedded `SearchHits` object containing eventually returned inner hits) * The retrieved entity of type .SearchHits diff --git a/src/main/java/org/springframework/data/elasticsearch/core/AbstractElasticsearchTemplate.java b/src/main/java/org/springframework/data/elasticsearch/core/AbstractElasticsearchTemplate.java index 605275bd..98f3d2cc 100644 --- a/src/main/java/org/springframework/data/elasticsearch/core/AbstractElasticsearchTemplate.java +++ b/src/main/java/org/springframework/data/elasticsearch/core/AbstractElasticsearchTemplate.java @@ -624,7 +624,7 @@ public abstract class AbstractElasticsearchTemplate implements ElasticsearchOper @Override public SearchHits doWith(SearchDocumentResponse response) { List entities = response.getSearchDocuments().stream().map(delegate::doWith).collect(Collectors.toList()); - return SearchHitMapping.mappingFor(type, elasticsearchConverter.getMappingContext()).mapHits(response, entities); + return SearchHitMapping.mappingFor(type, elasticsearchConverter).mapHits(response, entities); } } @@ -644,8 +644,7 @@ public abstract class AbstractElasticsearchTemplate implements ElasticsearchOper @Override public SearchScrollHits doWith(SearchDocumentResponse response) { List entities = response.getSearchDocuments().stream().map(delegate::doWith).collect(Collectors.toList()); - return SearchHitMapping.mappingFor(type, elasticsearchConverter.getMappingContext()).mapScrollHits(response, - entities); + return SearchHitMapping.mappingFor(type, elasticsearchConverter).mapScrollHits(response, entities); } } // endregion diff --git a/src/main/java/org/springframework/data/elasticsearch/core/ReactiveElasticsearchTemplate.java b/src/main/java/org/springframework/data/elasticsearch/core/ReactiveElasticsearchTemplate.java index 4b353439..8a0e3dae 100644 --- a/src/main/java/org/springframework/data/elasticsearch/core/ReactiveElasticsearchTemplate.java +++ b/src/main/java/org/springframework/data/elasticsearch/core/ReactiveElasticsearchTemplate.java @@ -878,7 +878,7 @@ public class ReactiveElasticsearchTemplate implements ReactiveElasticsearchOpera @Override public Mono> doWith(SearchDocument response) { return delegate.doWith(response) - .map(entity -> SearchHitMapping.mappingFor(type, converter.getMappingContext()).mapHit(response, entity)); + .map(entity -> SearchHitMapping.mappingFor(type, converter).mapHit(response, entity)); } } diff --git a/src/main/java/org/springframework/data/elasticsearch/core/SearchHit.java b/src/main/java/org/springframework/data/elasticsearch/core/SearchHit.java index a33c737e..1c4a59bf 100644 --- a/src/main/java/org/springframework/data/elasticsearch/core/SearchHit.java +++ b/src/main/java/org/springframework/data/elasticsearch/core/SearchHit.java @@ -23,6 +23,7 @@ import java.util.List; import java.util.Map; import java.util.stream.Collectors; +import org.springframework.data.elasticsearch.core.document.NestedMetaData; import org.springframework.lang.Nullable; import org.springframework.util.Assert; @@ -41,17 +42,32 @@ public class SearchHit { private final List sortValues; private final T content; private final Map> highlightFields = new LinkedHashMap<>(); + private final Map> innerHits = new LinkedHashMap<>(); + @Nullable private final NestedMetaData nestedMetaData; public SearchHit(@Nullable String index, @Nullable String id, float score, @Nullable Object[] sortValues, @Nullable Map> highlightFields, T content) { + this(index, id, score, sortValues, highlightFields, null, null, content); + } + + public SearchHit(@Nullable String index, @Nullable String id, float score, @Nullable Object[] sortValues, + @Nullable Map> highlightFields, @Nullable Map> innerHits, + @Nullable NestedMetaData nestedMetaData, T content) { this.index = index; this.id = id; this.score = score; this.sortValues = (sortValues != null) ? Arrays.asList(sortValues) : new ArrayList<>(); + if (highlightFields != null) { this.highlightFields.putAll(highlightFields); } + if (innerHits != null) { + this.innerHits.putAll(innerHits); + } + + this.nestedMetaData = nestedMetaData; + this.content = content; } @@ -90,6 +106,9 @@ public class SearchHit { return Collections.unmodifiableList(sortValues); } + /** + * @return the map from field names to highlight values, never {@literal null} + */ public Map> getHighlightFields() { return Collections.unmodifiableMap(highlightFields.entrySet().stream() .collect(Collectors.toMap(Map.Entry::getKey, entry -> Collections.unmodifiableList(entry.getValue())))); @@ -108,6 +127,39 @@ public class SearchHit { return Collections.unmodifiableList(highlightFields.getOrDefault(field, Collections.emptyList())); } + /** + * returns the {@link SearchHits} for the inner hits with the given name. If the inner hits could be mapped to a + * nested entity class, the returned data will be of this type, otherwise + * {{@link org.springframework.data.elasticsearch.core.document.SearchDocument}} instances are returned in this + * {@link SearchHits} object. + * + * @param name the inner hits name + * @return {@link SearchHits} if available, otherwise {@literal null} + */ + @Nullable + public SearchHits getInnerHits(String name) { + return innerHits.get(name); + } + + /** + * @return the map from inner_hits names to inner hits, in a {@link SearchHits} object, never {@literal null} + * @since 4.1 + */ + public Map> getInnerHits() { + return innerHits; + } + + /** + * If this is a nested inner hit, return the nested metadata information + * + * @return {{@link NestedMetaData} + * @since 4.1 + */ + @Nullable + public NestedMetaData getNestedMetaData() { + return nestedMetaData; + } + @Override public String toString() { return "SearchHit{" + "id='" + id + '\'' + ", score=" + score + ", sortValues=" + sortValues + ", content=" diff --git a/src/main/java/org/springframework/data/elasticsearch/core/SearchHitMapping.java b/src/main/java/org/springframework/data/elasticsearch/core/SearchHitMapping.java index f0a65adb..d6a0b393 100644 --- a/src/main/java/org/springframework/data/elasticsearch/core/SearchHitMapping.java +++ b/src/main/java/org/springframework/data/elasticsearch/core/SearchHitMapping.java @@ -16,11 +16,18 @@ package org.springframework.data.elasticsearch.core; import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.stream.Collectors; import org.elasticsearch.search.aggregations.Aggregations; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.data.elasticsearch.core.convert.ElasticsearchConverter; +import org.springframework.data.elasticsearch.core.document.Document; +import org.springframework.data.elasticsearch.core.document.NestedMetaData; import org.springframework.data.elasticsearch.core.document.SearchDocument; import org.springframework.data.elasticsearch.core.document.SearchDocumentResponse; import org.springframework.data.elasticsearch.core.mapping.ElasticsearchPersistentEntity; @@ -39,36 +46,24 @@ import org.springframework.util.Assert; * @since 4.0 */ class SearchHitMapping { + + private static final Logger LOGGER = LoggerFactory.getLogger(SearchHitMapping.class); + private final Class type; + private final ElasticsearchConverter converter; private final MappingContext, ElasticsearchPersistentProperty> mappingContext; - private SearchHitMapping(Class type, - MappingContext, ElasticsearchPersistentProperty> context) { - + private SearchHitMapping(Class type, ElasticsearchConverter converter) { Assert.notNull(type, "type is null"); - Assert.notNull(context, "context is null"); + Assert.notNull(converter, "converter is null"); this.type = type; - this.mappingContext = context; + this.converter = converter; + this.mappingContext = converter.getMappingContext(); } - static SearchHitMapping mappingFor(Class entityClass, - MappingContext, ElasticsearchPersistentProperty> context) { - return new SearchHitMapping<>(entityClass, context); - } - - SearchHit mapHit(SearchDocument searchDocument, T content) { - - Assert.notNull(searchDocument, "searchDocument is null"); - Assert.notNull(content, "content is null"); - - String index = searchDocument.getIndex(); - String id = searchDocument.hasId() ? searchDocument.getId() : null; - float score = searchDocument.getScore(); - Object[] sortValues = searchDocument.getSortValues(); - Map> highlightFields = getHighlightsAndRemapFieldNames(searchDocument); - - return new SearchHit<>(index, id, score, sortValues, highlightFields, content); + static SearchHitMapping mappingFor(Class entityClass, ElasticsearchConverter converter) { + return new SearchHitMapping<>(entityClass, converter); } SearchHits mapHits(SearchDocumentResponse searchDocumentResponse, List contents) { @@ -105,6 +100,21 @@ class SearchHitMapping { return new SearchHitsImpl<>(totalHits, totalHitsRelation, maxScore, scrollId, searchHits, aggregations); } + SearchHit mapHit(SearchDocument searchDocument, T content) { + + Assert.notNull(searchDocument, "searchDocument is null"); + Assert.notNull(content, "content is null"); + + return new SearchHit(searchDocument.getIndex(), // + searchDocument.hasId() ? searchDocument.getId() : null, // + searchDocument.getScore(), // + searchDocument.getSortValues(), // + getHighlightsAndRemapFieldNames(searchDocument), // + mapInnerHits(searchDocument), // + searchDocument.getNestedMetaData(), // + content); // + } + @Nullable private Map> getHighlightsAndRemapFieldNames(SearchDocument searchDocument) { Map> highlightFields = searchDocument.getHighlightFields(); @@ -123,4 +133,131 @@ class SearchHitMapping { return property != null ? property.getName() : entry.getKey(); }, Map.Entry::getValue)); } + + private Map> mapInnerHits(SearchDocument searchDocument) { + + Map> innerHits = new LinkedHashMap<>(); + Map documentInnerHits = searchDocument.getInnerHits(); + + if (documentInnerHits != null && documentInnerHits.size() > 0) { + + SearchHitMapping searchDocumentSearchHitMapping = SearchHitMapping + .mappingFor(SearchDocument.class, converter); + + for (Map.Entry entry : documentInnerHits.entrySet()) { + SearchDocumentResponse searchDocumentResponse = entry.getValue(); + + SearchHits searchHits = searchDocumentSearchHitMapping + .mapHitsFromResponse(searchDocumentResponse, searchDocumentResponse.getSearchDocuments()); + + // map Documents to real objects + SearchHits mappedSearchHits = mapInnerDocuments(searchHits, type); + + innerHits.put(entry.getKey(), mappedSearchHits); + } + + } + return innerHits; + } + + /** + * try to convert the SearchDocument instances to instances of the inner property class. + * + * @param searchHits {@link SearchHits} containing {@link Document} instances + * @param type the class of the containing class + * @return a new {@link SearchHits} instance containing the mapped objects or the original inout if any error occurs + */ + private SearchHits mapInnerDocuments(SearchHits searchHits, Class type) { + + if (searchHits.getTotalHits() == 0) { + return searchHits; + } + + try { + NestedMetaData nestedMetaData = searchHits.getSearchHit(0).getContent().getNestedMetaData(); + ElasticsearchPersistentEntityWithNestedMetaData persistentEntityWithNestedMetaData = getPersistentEntity( + mappingContext.getPersistentEntity(type), nestedMetaData); + + List> convertedSearchHits = new ArrayList<>(); + + if (persistentEntityWithNestedMetaData.entity != null) { + Class targetType = persistentEntityWithNestedMetaData.entity.getType(); + // convert the list of SearchHit to list of SearchHit + searchHits.getSearchHits().forEach(searchHit -> { + SearchDocument searchDocument = searchHit.getContent(); + + Object targetObject = converter.read(targetType, searchDocument); + convertedSearchHits.add(new SearchHit(searchDocument.getIndex(), // + searchDocument.getId(), // + searchDocument.getScore(), // + searchDocument.getSortValues(), // + searchDocument.getHighlightFields(), // + mapInnerHits(searchDocument), // + persistentEntityWithNestedMetaData.nestedMetaData, // + targetObject)); + }); + + String scrollId = null; + if (searchHits instanceof SearchHitsImpl) { + scrollId = ((SearchHitsImpl) searchHits).getScrollId(); + } + + return new SearchHitsImpl<>(searchHits.getTotalHits(), // + searchHits.getTotalHitsRelation(), // + searchHits.getMaxScore(), // + scrollId, // + convertedSearchHits, // + searchHits.getAggregations()); + } + } catch (Exception e) { + LOGGER.warn("Could not map inner_hits", e); + } + + return searchHits; + } + + /** + * find a {@link ElasticsearchPersistentEntity} following the property chain defined by the nested metadata + * + * @param persistentEntity base entity + * @param nestedMetaData nested metadata + * @return The found entity or null + */ + @Nullable + private ElasticsearchPersistentEntityWithNestedMetaData getPersistentEntity( + @Nullable ElasticsearchPersistentEntity persistentEntity, @Nullable NestedMetaData nestedMetaData) { + + NestedMetaData currentMetaData = nestedMetaData; + List mappedNestedMetaDatas = new LinkedList<>(); + + while (persistentEntity != null && currentMetaData != null) { + ElasticsearchPersistentProperty persistentProperty = persistentEntity + .getPersistentPropertyWithFieldName(currentMetaData.getField()); + + if (persistentProperty == null) { + persistentEntity = null; + } else { + persistentEntity = mappingContext.getPersistentEntity(persistentProperty.getActualType()); + mappedNestedMetaDatas.add(0, + NestedMetaData.of(persistentProperty.getName(), currentMetaData.getOffset(), null)); + currentMetaData = currentMetaData.getChild(); + } + } + + NestedMetaData mappedNestedMetaData = mappedNestedMetaDatas.stream().reduce(null, + (result, nmd) -> NestedMetaData.of(nmd.getField(), nmd.getOffset(), result)); + + return new ElasticsearchPersistentEntityWithNestedMetaData(persistentEntity, mappedNestedMetaData); + } + + private static class ElasticsearchPersistentEntityWithNestedMetaData { + @Nullable private ElasticsearchPersistentEntity entity; + private NestedMetaData nestedMetaData; + + public ElasticsearchPersistentEntityWithNestedMetaData(@Nullable ElasticsearchPersistentEntity entity, + NestedMetaData nestedMetaData) { + this.entity = entity; + this.nestedMetaData = nestedMetaData; + } + } } diff --git a/src/main/java/org/springframework/data/elasticsearch/core/document/DocumentAdapters.java b/src/main/java/org/springframework/data/elasticsearch/core/document/DocumentAdapters.java index 34c5129a..7f2d9b5e 100644 --- a/src/main/java/org/springframework/data/elasticsearch/core/document/DocumentAdapters.java +++ b/src/main/java/org/springframework/data/elasticsearch/core/document/DocumentAdapters.java @@ -22,6 +22,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; @@ -37,6 +38,7 @@ import org.elasticsearch.common.document.DocumentField; import org.elasticsearch.common.text.Text; import org.elasticsearch.index.get.GetResult; import org.elasticsearch.search.SearchHit; +import org.elasticsearch.search.SearchHits; import org.springframework.data.elasticsearch.ElasticsearchException; import org.springframework.lang.Nullable; import org.springframework.util.Assert; @@ -155,12 +157,28 @@ public class DocumentAdapters { .collect(Collectors.toMap(Map.Entry::getKey, entry -> Arrays.stream(entry.getValue().getFragments()).map(Text::string).collect(Collectors.toList())))); + Map innerHits = new LinkedHashMap<>(); + Map sourceInnerHits = source.getInnerHits(); + + if (sourceInnerHits != null) { + sourceInnerHits.forEach((name, searchHits) -> { + innerHits.put(name, SearchDocumentResponse.from(searchHits, null, null)); + }); + } + + NestedMetaData nestedMetaData = null; + + if (source.getNestedIdentity() != null) { + nestedMetaData = from(source.getNestedIdentity()); + } + BytesReference sourceRef = source.getSourceRef(); if (sourceRef == null || sourceRef.length() == 0) { - return new SearchDocumentAdapter(source.getScore(), source.getSortValues(), source.getFields(), highlightFields, - fromDocumentFields(source, source.getIndex(), source.getId(), source.getVersion(), source.getSeqNo(), - source.getPrimaryTerm())); + return new SearchDocumentAdapter( + source.getScore(), source.getSortValues(), source.getFields(), highlightFields, fromDocumentFields(source, + source.getIndex(), source.getId(), source.getVersion(), source.getSeqNo(), source.getPrimaryTerm()), + innerHits, nestedMetaData); } Document document = Document.from(source.getSourceAsMap()); @@ -174,7 +192,18 @@ public class DocumentAdapters { document.setPrimaryTerm(source.getPrimaryTerm()); return new SearchDocumentAdapter(source.getScore(), source.getSortValues(), source.getFields(), highlightFields, - document); + document, innerHits, nestedMetaData); + } + + private static NestedMetaData from(SearchHit.NestedIdentity nestedIdentity) { + + NestedMetaData child = null; + + if (nestedIdentity.getChild() != null) { + child = from(nestedIdentity.getChild()); + } + + return NestedMetaData.of(nestedIdentity.getField().string(), nestedIdentity.getOffset(), child); } /** @@ -427,15 +456,20 @@ public class DocumentAdapters { private final Map> fields = new HashMap<>(); private final Document delegate; private final Map> highlightFields = new HashMap<>(); + private final Map innerHits = new HashMap<>(); + @Nullable private final NestedMetaData nestedMetaData; SearchDocumentAdapter(float score, Object[] sortValues, Map fields, - Map> highlightFields, Document delegate) { + Map> highlightFields, Document delegate, Map innerHits, + @Nullable NestedMetaData nestedMetaData) { this.score = score; this.sortValues = sortValues; this.delegate = delegate; fields.forEach((name, documentField) -> this.fields.put(name, documentField.getValues())); this.highlightFields.putAll(highlightFields); + this.innerHits.putAll(innerHits); + this.nestedMetaData = nestedMetaData; } @Override @@ -530,6 +564,17 @@ public class DocumentAdapters { delegate.setPrimaryTerm(primaryTerm); } + @Override + public Map getInnerHits() { + return innerHits; + } + + @Override + @Nullable + public NestedMetaData getNestedMetaData() { + return nestedMetaData; + } + @Override @Nullable public T get(Object key, Class type) { diff --git a/src/main/java/org/springframework/data/elasticsearch/core/document/NestedMetaData.java b/src/main/java/org/springframework/data/elasticsearch/core/document/NestedMetaData.java new file mode 100644 index 00000000..ea140536 --- /dev/null +++ b/src/main/java/org/springframework/data/elasticsearch/core/document/NestedMetaData.java @@ -0,0 +1,53 @@ +/* + * Copyright 2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.elasticsearch.core.document; + +import org.springframework.lang.Nullable; + +/** + * meta data returned for nested inner hits. + * + * @author Peter-Josef Meisch + */ +public class NestedMetaData { + + private final String field; + private final int offset; + @Nullable private final NestedMetaData child; + + public static NestedMetaData of(String field, int offset, @Nullable NestedMetaData nested) { + return new NestedMetaData(field, offset, nested); + } + + private NestedMetaData(String field, int offset, @Nullable NestedMetaData child) { + this.field = field; + this.offset = offset; + this.child = child; + } + + public String getField() { + return field; + } + + public int getOffset() { + return offset; + } + + @Nullable + public NestedMetaData getChild() { + return child; + } +} diff --git a/src/main/java/org/springframework/data/elasticsearch/core/document/SearchDocument.java b/src/main/java/org/springframework/data/elasticsearch/core/document/SearchDocument.java index 3caafc55..ab46c402 100644 --- a/src/main/java/org/springframework/data/elasticsearch/core/document/SearchDocument.java +++ b/src/main/java/org/springframework/data/elasticsearch/core/document/SearchDocument.java @@ -69,5 +69,24 @@ public interface SearchDocument extends Document { */ @Nullable default Map> getHighlightFields() { - return null;} + return null; + } + + /** + * @return the innerHits for the SearchHit + * @since 4.1 + */ + @Nullable + default Map getInnerHits() { + return null; + } + + /** + * @return the nested metadata in case this is a nested inner hit. + * @since 4.1 + */ + @Nullable + default NestedMetaData getNestedMetaData() { + return null; + } } diff --git a/src/main/java/org/springframework/data/elasticsearch/core/document/SearchDocumentResponse.java b/src/main/java/org/springframework/data/elasticsearch/core/document/SearchDocumentResponse.java index 489c5cd4..cadbbef4 100644 --- a/src/main/java/org/springframework/data/elasticsearch/core/document/SearchDocumentResponse.java +++ b/src/main/java/org/springframework/data/elasticsearch/core/document/SearchDocumentResponse.java @@ -22,7 +22,9 @@ import java.util.stream.StreamSupport; import org.apache.lucene.search.TotalHits; import org.elasticsearch.action.search.SearchResponse; +import org.elasticsearch.search.SearchHits; import org.elasticsearch.search.aggregations.Aggregations; +import org.springframework.lang.Nullable; import org.springframework.util.Assert; /** @@ -42,7 +44,7 @@ public class SearchDocumentResponse { private final Aggregations aggregations; private SearchDocumentResponse(long totalHits, String totalHitsRelation, float maxScore, String scrollId, - List searchDocuments, Aggregations aggregations) { + List searchDocuments, Aggregations aggregations) { this.totalHits = totalHits; this.totalHitsRelation = totalHitsRelation; this.maxScore = maxScore; @@ -78,27 +80,45 @@ public class SearchDocumentResponse { /** * creates a SearchDocumentResponse from the {@link SearchResponse} * - * @param searchResponse - * must not be {@literal null} + * @param searchResponse must not be {@literal null} * @return the SearchDocumentResponse */ public static SearchDocumentResponse from(SearchResponse searchResponse) { + Assert.notNull(searchResponse, "searchResponse must not be null"); - TotalHits responseTotalHits = searchResponse.getHits().getTotalHits(); + Aggregations aggregations = searchResponse.getAggregations(); + String scrollId = searchResponse.getScrollId(); + + SearchHits searchHits = searchResponse.getHits(); + + SearchDocumentResponse searchDocumentResponse = from(searchHits, scrollId, aggregations); + return searchDocumentResponse; + } + + /** + * creates a {@link SearchDocumentResponse} from {@link SearchHits} with the given scrollId and aggregations + * + * @param searchHits the {@link SearchHits} to process + * @param scrollId scrollId + * @param aggregations aggregations + * @return the {@link SearchDocumentResponse} + * @since 4.1 + */ + public static SearchDocumentResponse from(SearchHits searchHits, @Nullable String scrollId, + @Nullable Aggregations aggregations) { + TotalHits responseTotalHits = searchHits.getTotalHits(); long totalHits = responseTotalHits.value; String totalHitsRelation = responseTotalHits.relation.name(); - float maxScore = searchResponse.getHits().getMaxScore(); - String scrollId = searchResponse.getScrollId(); + float maxScore = searchHits.getMaxScore(); - List searchDocuments = StreamSupport.stream(searchResponse.getHits().spliterator(), false) // + List searchDocuments = StreamSupport.stream(searchHits.spliterator(), false) // .filter(Objects::nonNull) // .map(DocumentAdapters::from) // .collect(Collectors.toList()); - Aggregations aggregations = searchResponse.getAggregations(); - return new SearchDocumentResponse(totalHits, totalHitsRelation, maxScore, scrollId, searchDocuments, aggregations); } + } diff --git a/src/test/java/org/springframework/data/elasticsearch/core/InnerHitsTests.java b/src/test/java/org/springframework/data/elasticsearch/core/InnerHitsTests.java new file mode 100644 index 00000000..a7a6f4b5 --- /dev/null +++ b/src/test/java/org/springframework/data/elasticsearch/core/InnerHitsTests.java @@ -0,0 +1,170 @@ +/* + * Copyright 2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.elasticsearch.core; + +import static org.assertj.core.api.Assertions.*; +import static org.elasticsearch.index.query.QueryBuilders.*; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.RequiredArgsConstructor; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; + +import org.apache.lucene.search.join.ScoreMode; +import org.assertj.core.api.SoftAssertions; +import org.elasticsearch.index.query.InnerHitBuilder; +import org.elasticsearch.index.query.NestedQueryBuilder; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.data.annotation.Id; +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.document.NestedMetaData; +import org.springframework.data.elasticsearch.core.query.NativeSearchQuery; +import org.springframework.data.elasticsearch.core.query.NativeSearchQueryBuilder; +import org.springframework.data.elasticsearch.junit.jupiter.ElasticsearchRestTemplateConfiguration; +import org.springframework.data.elasticsearch.junit.jupiter.SpringIntegrationTest; +import org.springframework.lang.Nullable; +import org.springframework.test.context.ContextConfiguration; + +/** + * Testing the querying and parsing of inner_hits. + * + * @author Peter-Josef Meisch + */ +@SpringIntegrationTest +@ContextConfiguration(classes = { InnerHitsTests.Config.class }) +public class InnerHitsTests { + + public static final String INDEX_NAME = "tests-inner-hits"; + + @Configuration + @Import({ ElasticsearchRestTemplateConfiguration.class }) + static class Config {} + + @Autowired ElasticsearchOperations operations; + @Nullable IndexOperations indexOps; + + @BeforeEach + void setUp() { + indexOps = operations.indexOps(City.class); + indexOps.create(); + indexOps.putMapping(City.class); + + Inhabitant john = new Inhabitant("John", "Smith"); + Inhabitant carla = new Inhabitant("Carla", "Miller"); + House cornerHouse = new House("Round the corner", "7", Arrays.asList(john, carla)); + City metropole = new City("Metropole", Arrays.asList(cornerHouse)); + + Inhabitant jack = new Inhabitant("Jack", "Wayne"); + Inhabitant emmy = new Inhabitant("Emmy", "Stone"); + House mainStreet = new House("Main Street", "42", Arrays.asList(jack, emmy)); + City village = new City("Village", Arrays.asList(mainStreet)); + + operations.save(Arrays.asList(metropole, village)); + indexOps.refresh(); + } + + @AfterEach + void tearDown() { + indexOps.delete(); + } + + @Test + void shouldReturnInnerHits() { + String innerHitName = "inner_hit_name"; + + NativeSearchQueryBuilder queryBuilder = new NativeSearchQueryBuilder(); + + NestedQueryBuilder nestedQueryBuilder = nestedQuery("hou-ses.in-habi-tants", + matchQuery("hou-ses.in-habi-tants.first-name", "Carla"), ScoreMode.Avg); + nestedQueryBuilder.innerHit(new InnerHitBuilder(innerHitName)); + queryBuilder.withQuery(nestedQueryBuilder); + + NativeSearchQuery query = queryBuilder.build(); + + SoftAssertions softly = new SoftAssertions(); + SearchHits searchHits = operations.search(query, City.class); + + softly.assertThat(searchHits.getTotalHits()).isEqualTo(1); + + SearchHit searchHit = searchHits.getSearchHit(0); + softly.assertThat(searchHit.getInnerHits()).hasSize(1); + + SearchHits innerHits = searchHit.getInnerHits(innerHitName); + softly.assertThat(innerHits).hasSize(1); + + SearchHit innerHit = innerHits.getSearchHit(0); + Object content = innerHit.getContent(); + assertThat(content).isInstanceOf(Inhabitant.class); + Inhabitant inhabitant = (Inhabitant) content; + softly.assertThat(inhabitant.getFirstName()).isEqualTo("Carla"); + softly.assertThat(inhabitant.getLastName()).isEqualTo("Miller"); + + NestedMetaData nestedMetaData = innerHit.getNestedMetaData(); + softly.assertThat(nestedMetaData.getField()).isEqualTo("houses"); + softly.assertThat(nestedMetaData.getOffset()).isEqualTo(0); + softly.assertThat(nestedMetaData.getChild().getField()).isEqualTo("inhabitants"); + softly.assertThat(nestedMetaData.getChild().getOffset()).isEqualTo(1); + + softly.assertAll(); + } + + @Data + @AllArgsConstructor + @RequiredArgsConstructor + @Document(indexName = INDEX_NAME) + static class City { + + @Id private String name; + + // NOTE: using a custom names here to cover property name matching + @Field(name = "hou-ses", type = FieldType.Nested) private Collection houses = new ArrayList<>(); + } + + @Data + @AllArgsConstructor + @RequiredArgsConstructor + static class House { + + @Field(type = FieldType.Text) private String street; + + @Field(type = FieldType.Text) private String streetNumber; + + // NOTE: using a custom names here to cover property name matching + @Field(name = "in-habi-tants", type = FieldType.Nested) private List inhabitants = new ArrayList<>(); + } + + @Data + @AllArgsConstructor + @RequiredArgsConstructor + static class Inhabitant { + // NOTE: using a custom names here to cover property name matching + + @Field(name = "first-name", type = FieldType.Text) private String firstName; + + @Field(name = "last-name", type = FieldType.Text) private String lastName; + } +} diff --git a/src/test/java/org/springframework/data/elasticsearch/core/InnerHitsTransportTests.java b/src/test/java/org/springframework/data/elasticsearch/core/InnerHitsTransportTests.java new file mode 100644 index 00000000..8ff213de --- /dev/null +++ b/src/test/java/org/springframework/data/elasticsearch/core/InnerHitsTransportTests.java @@ -0,0 +1,31 @@ +/* + * Copyright 2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.elasticsearch.core; + +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.data.elasticsearch.junit.jupiter.ElasticsearchTemplateConfiguration; +import org.springframework.test.context.ContextConfiguration; + +/** + * @author Peter-Josef Meisch + */ +@ContextConfiguration(classes = { InnerHitsTransportTests.Config.class }) +public class InnerHitsTransportTests extends InnerHitsTests { + @Configuration + @Import({ ElasticsearchTemplateConfiguration.class }) + static class Config {} +}