diff --git a/src/main/java/org/springframework/data/elasticsearch/core/RequestFactory.java b/src/main/java/org/springframework/data/elasticsearch/core/RequestFactory.java index 9d51e7053..eeee31524 100644 --- a/src/main/java/org/springframework/data/elasticsearch/core/RequestFactory.java +++ b/src/main/java/org/springframework/data/elasticsearch/core/RequestFactory.java @@ -1144,6 +1144,8 @@ private SearchRequest prepareSearchRequest(Query query, @Nullable Class clazz sourceBuilder.timeout(timeout); } + sourceBuilder.explain(query.getExplain()); + request.source(sourceBuilder); return request; } @@ -1224,6 +1226,8 @@ private SearchRequestBuilder prepareSearchRequestBuilder(Query query, Client cli searchRequestBuilder.setTimeout(timeout); } + searchRequestBuilder.setExplain(query.getExplain()); + return searchRequestBuilder; } 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 119f19be3..327066b47 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.Map; import java.util.stream.Collectors; +import org.springframework.data.elasticsearch.core.document.Explanation; import org.springframework.data.elasticsearch.core.document.NestedMetaData; import org.springframework.lang.Nullable; import org.springframework.util.Assert; @@ -44,16 +45,18 @@ public class SearchHit { private final Map> highlightFields = new LinkedHashMap<>(); private final Map> innerHits = new LinkedHashMap<>(); @Nullable private final NestedMetaData nestedMetaData; - @Nullable private String routing; + @Nullable private final String routing; + @Nullable private final Explanation explanation; public SearchHit(@Nullable String index, @Nullable String id, @Nullable String routing, float score, @Nullable Object[] sortValues, @Nullable Map> highlightFields, T content) { - this(index, id, routing, score, sortValues, highlightFields, null, null, content); + this(index, id, routing, score, sortValues, highlightFields, null, null, null, content); } public SearchHit(@Nullable String index, @Nullable String id, @Nullable String routing, float score, @Nullable Object[] sortValues, @Nullable Map> highlightFields, - @Nullable Map> innerHits, @Nullable NestedMetaData nestedMetaData, T content) { + @Nullable Map> innerHits, @Nullable NestedMetaData nestedMetaData, + @Nullable Explanation explanation, T content) { this.index = index; this.id = id; this.routing = routing; @@ -69,7 +72,7 @@ public SearchHit(@Nullable String index, @Nullable String id, @Nullable String r } this.nestedMetaData = nestedMetaData; - + this.explanation = explanation; this.content = content; } @@ -176,4 +179,13 @@ public String toString() { public String getRouting() { return routing; } + + /** + * @return the explanation for this SearchHit. + * @since 4.2 + */ + @Nullable + public Explanation getExplanation() { + return explanation; + } } 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 43ff9e97f..3971dcef6 100644 --- a/src/main/java/org/springframework/data/elasticsearch/core/SearchHitMapping.java +++ b/src/main/java/org/springframework/data/elasticsearch/core/SearchHitMapping.java @@ -113,6 +113,7 @@ SearchHit mapHit(SearchDocument searchDocument, T content) { getHighlightsAndRemapFieldNames(searchDocument), // mapInnerHits(searchDocument), // searchDocument.getNestedMetaData(), // + searchDocument.getExplanation(), // content); // } @@ -196,6 +197,7 @@ private SearchHits mapInnerDocuments(SearchHits searchHits, C searchDocument.getHighlightFields(), // searchHit.getInnerHits(), // persistentEntityWithNestedMetaData.nestedMetaData, // + searchHit.getExplanation(), // targetObject)); }); 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 02ab8358e..e42057fd3 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 @@ -161,16 +161,12 @@ public static SearchDocument from(SearchHit source) { Map sourceInnerHits = source.getInnerHits(); if (sourceInnerHits != null) { - sourceInnerHits.forEach((name, searchHits) -> { - innerHits.put(name, SearchDocumentResponse.from(searchHits, null, null)); - }); + sourceInnerHits + .forEach((name, searchHits) -> innerHits.put(name, SearchDocumentResponse.from(searchHits, null, null))); } - NestedMetaData nestedMetaData = null; - - if (source.getNestedIdentity() != null) { - nestedMetaData = from(source.getNestedIdentity()); - } + NestedMetaData nestedMetaData = from(source.getNestedIdentity()); + Explanation explanation = from(source.getExplanation()); BytesReference sourceRef = source.getSourceRef(); @@ -178,7 +174,7 @@ public static SearchDocument from(SearchHit source) { return new SearchDocumentAdapter( source.getScore(), source.getSortValues(), source.getFields(), highlightFields, fromDocumentFields(source, source.getIndex(), source.getId(), source.getVersion(), source.getSeqNo(), source.getPrimaryTerm()), - innerHits, nestedMetaData); + innerHits, nestedMetaData, explanation); } Document document = Document.from(source.getSourceAsMap()); @@ -192,17 +188,32 @@ public static SearchDocument from(SearchHit source) { document.setPrimaryTerm(source.getPrimaryTerm()); return new SearchDocumentAdapter(source.getScore(), source.getSortValues(), source.getFields(), highlightFields, - document, innerHits, nestedMetaData); + document, innerHits, nestedMetaData, explanation); } - private static NestedMetaData from(SearchHit.NestedIdentity nestedIdentity) { + @Nullable + private static Explanation from(@Nullable org.apache.lucene.search.Explanation explanation) { - NestedMetaData child = null; + if (explanation == null) { + return null; + } - if (nestedIdentity.getChild() != null) { - child = from(nestedIdentity.getChild()); + List details = new ArrayList<>(); + for (org.apache.lucene.search.Explanation detail : explanation.getDetails()) { + details.add(from(detail)); } + return new Explanation(explanation.isMatch(), explanation.getValue().doubleValue(), explanation.getDescription(), + details); + } + + @Nullable + private static NestedMetaData from(@Nullable SearchHit.NestedIdentity nestedIdentity) { + + if (nestedIdentity == null) { + return null; + } + NestedMetaData child = from(nestedIdentity.getChild()); return NestedMetaData.of(nestedIdentity.getField().string(), nestedIdentity.getOffset(), child); } @@ -210,7 +221,7 @@ private static NestedMetaData from(SearchHit.NestedIdentity nestedIdentity) { * Create an unmodifiable {@link Document} from {@link Iterable} of {@link DocumentField}s. * * @param documentFields the {@link DocumentField}s backing the {@link Document}. - * @param index + * @param index the index where the Document was found * @return the adapted {@link Document}. */ public static Document fromDocumentFields(Iterable documentFields, String index, String id, @@ -458,10 +469,11 @@ static class SearchDocumentAdapter implements SearchDocument { private final Map> highlightFields = new HashMap<>(); private final Map innerHits = new HashMap<>(); @Nullable private final NestedMetaData nestedMetaData; + @Nullable private final Explanation explanation; SearchDocumentAdapter(float score, Object[] sortValues, Map fields, Map> highlightFields, Document delegate, Map innerHits, - @Nullable NestedMetaData nestedMetaData) { + @Nullable NestedMetaData nestedMetaData, @Nullable Explanation explanation) { this.score = score; this.sortValues = sortValues; @@ -470,6 +482,7 @@ static class SearchDocumentAdapter implements SearchDocument { this.highlightFields.putAll(highlightFields); this.innerHits.putAll(innerHits); this.nestedMetaData = nestedMetaData; + this.explanation = explanation; } @Override @@ -646,6 +659,12 @@ public Set> entrySet() { return delegate.entrySet(); } + @Override + @Nullable + public Explanation getExplanation() { + return explanation; + } + @Override public boolean equals(Object o) { if (this == o) { diff --git a/src/main/java/org/springframework/data/elasticsearch/core/document/Explanation.java b/src/main/java/org/springframework/data/elasticsearch/core/document/Explanation.java new file mode 100644 index 000000000..92e7326d8 --- /dev/null +++ b/src/main/java/org/springframework/data/elasticsearch/core/document/Explanation.java @@ -0,0 +1,99 @@ +/* + * Copyright 2021 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 java.util.List; +import java.util.Objects; + +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + +/** + * class that holds explanations returned from an Elasticsearch search. + * + * @author Peter-Josef Meisch + */ +public class Explanation { + private final boolean match; + private final Double value; + @Nullable private final String description; + private final List details; + + public Explanation(boolean match, Double value, @Nullable String description, List details) { + + Assert.notNull(value, "value must not be null"); + Assert.notNull(details, "details must not be null"); + + this.match = match; + this.value = value; + this.description = description; + this.details = details; + } + + public boolean isMatch() { + return match; + } + + public Double getValue() { + return value; + } + + @Nullable + public String getDescription() { + return description; + } + + public List getDetails() { + return details; + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; + + Explanation that = (Explanation) o; + + if (match != that.match) + return false; + if (!value.equals(that.value)) + return false; + if (!Objects.equals(description, that.description)) + return false; + return details.equals(that.details); + } + + @Override + public int hashCode() { + int result = (match ? 1 : 0); + result = 31 * result + value.hashCode(); + result = 31 * result + (description != null ? description.hashCode() : 0); + result = 31 * result + details.hashCode(); + return result; + } + + @Override + public String toString() { + return "Explanation{" + // + "match=" + match + // + ", value=" + value + // + ", description='" + description + '\'' + // + ", details=" + details + // + '}'; // + } +} 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 b305a1ac5..17daec2df 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 @@ -21,7 +21,7 @@ import org.springframework.lang.Nullable; /** - * Extension to {@link Document} exposing a search response related data. + * Extension to {@link Document} exposing search response related data. * * @author Mark Paluch * @author Peter-Josef Meisch @@ -98,4 +98,11 @@ default NestedMetaData getNestedMetaData() { default String getRouting() { return getFieldValue("_routing"); } + + /** + * @return the explanation for the SearchHit. + * @since 4.2 + */ + @Nullable + Explanation getExplanation(); } diff --git a/src/main/java/org/springframework/data/elasticsearch/core/document/package-info.java b/src/main/java/org/springframework/data/elasticsearch/core/document/package-info.java index 02405154f..03bee729b 100644 --- a/src/main/java/org/springframework/data/elasticsearch/core/document/package-info.java +++ b/src/main/java/org/springframework/data/elasticsearch/core/document/package-info.java @@ -1,3 +1,6 @@ +/** + * Classes related to the Document structure of Elasticsearch documents and search responses. + */ @org.springframework.lang.NonNullApi @org.springframework.lang.NonNullFields package org.springframework.data.elasticsearch.core.document; diff --git a/src/main/java/org/springframework/data/elasticsearch/core/query/AbstractQuery.java b/src/main/java/org/springframework/data/elasticsearch/core/query/AbstractQuery.java index 0af25018b..b2d9e0a9d 100755 --- a/src/main/java/org/springframework/data/elasticsearch/core/query/AbstractQuery.java +++ b/src/main/java/org/springframework/data/elasticsearch/core/query/AbstractQuery.java @@ -61,6 +61,7 @@ abstract class AbstractQuery implements Query { @Nullable private Integer trackTotalHitsUpTo; @Nullable private Duration scrollTime; @Nullable private TimeValue timeout; + private boolean explain = false; @Override @Nullable @@ -270,4 +271,16 @@ public TimeValue getTimeout() { public void setTimeout(@Nullable TimeValue timeout) { this.timeout = timeout; } + + @Override + public boolean getExplain() { + return explain; + } + + /** + * @param explain the explain flag on the query. + */ + public void setExplain(boolean explain) { + this.explain = explain; + } } diff --git a/src/main/java/org/springframework/data/elasticsearch/core/query/Query.java b/src/main/java/org/springframework/data/elasticsearch/core/query/Query.java index 6520d9528..790870cce 100644 --- a/src/main/java/org/springframework/data/elasticsearch/core/query/Query.java +++ b/src/main/java/org/springframework/data/elasticsearch/core/query/Query.java @@ -285,4 +285,12 @@ default boolean hasScrollTime() { */ @Nullable TimeValue getTimeout(); + + /** + * @return {@literal true} when the query has the eplain parameter set, defaults to {@literal false} + * @since 4.2 + */ + default boolean getExplain() { + return false; + } } diff --git a/src/test/java/org/springframework/data/elasticsearch/core/DocumentAdaptersUnitTests.java b/src/test/java/org/springframework/data/elasticsearch/core/DocumentAdaptersUnitTests.java index 2b5c29d17..34c3b1246 100644 --- a/src/test/java/org/springframework/data/elasticsearch/core/DocumentAdaptersUnitTests.java +++ b/src/test/java/org/springframework/data/elasticsearch/core/DocumentAdaptersUnitTests.java @@ -21,6 +21,7 @@ import java.util.Arrays; import java.util.Collections; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import org.elasticsearch.action.get.GetResponse; @@ -31,9 +32,11 @@ import org.elasticsearch.index.shard.ShardId; import org.elasticsearch.search.SearchHit; import org.elasticsearch.search.SearchShardTarget; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.data.elasticsearch.core.document.Document; import org.springframework.data.elasticsearch.core.document.DocumentAdapters; +import org.springframework.data.elasticsearch.core.document.Explanation; import org.springframework.data.elasticsearch.core.document.SearchDocument; /** @@ -236,4 +239,27 @@ public void shouldAdaptSearchResponseSource() { assertThat(document.hasPrimaryTerm()).isTrue(); assertThat(document.getPrimaryTerm()).isEqualTo(2); } + + @Test // #725 + @DisplayName("should adapt returned explanations") + void shouldAdaptReturnedExplanations() { + + SearchHit searchHit = new SearchHit(42); + searchHit.explanation(org.apache.lucene.search.Explanation.match( // + 3.14, // + "explanation 3.14", // + Collections.singletonList(org.apache.lucene.search.Explanation.noMatch( // + "explanation noMatch", // + Collections.emptyList())))); + + SearchDocument searchDocument = DocumentAdapters.from(searchHit); + + Explanation explanation = searchDocument.getExplanation(); + assertThat(explanation).isNotNull(); + assertThat(explanation.isMatch()).isTrue(); + assertThat(explanation.getValue()).isEqualTo(3.14); + assertThat(explanation.getDescription()).isEqualTo("explanation 3.14"); + List details = explanation.getDetails(); + assertThat(details).containsExactly(new Explanation(false, 0.0, "explanation noMatch", Collections.emptyList())); + } } diff --git a/src/test/java/org/springframework/data/elasticsearch/core/ElasticsearchTemplateTests.java b/src/test/java/org/springframework/data/elasticsearch/core/ElasticsearchTemplateTests.java index 2893b0f68..59b8c1f25 100755 --- a/src/test/java/org/springframework/data/elasticsearch/core/ElasticsearchTemplateTests.java +++ b/src/test/java/org/springframework/data/elasticsearch/core/ElasticsearchTemplateTests.java @@ -33,7 +33,16 @@ import java.lang.Integer; import java.lang.Long; import java.lang.Object; -import java.util.*; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; import java.util.function.Function; import java.util.stream.Collectors; import java.util.stream.IntStream; @@ -74,6 +83,7 @@ import org.springframework.data.elasticsearch.annotations.JoinTypeRelations; import org.springframework.data.elasticsearch.annotations.MultiField; import org.springframework.data.elasticsearch.annotations.ScriptedField; +import org.springframework.data.elasticsearch.core.document.Explanation; import org.springframework.data.elasticsearch.core.geo.GeoPoint; import org.springframework.data.elasticsearch.core.index.AliasAction; import org.springframework.data.elasticsearch.core.index.AliasActionParameters; @@ -124,7 +134,6 @@ public abstract class ElasticsearchTemplateTests { @Autowired protected ElasticsearchOperations operations; protected IndexOperations indexOperations; - @BeforeEach public void before() { indexOperations = operations.indexOps(SampleEntity.class); @@ -1546,10 +1555,8 @@ void shouldDoUpdateByQueryForExistingDocument() { final UpdateQuery updateQuery = UpdateQuery.builder(query) .withScriptType(org.springframework.data.elasticsearch.core.ScriptType.INLINE) - .withScript("ctx._source['message'] = params['newMessage']") - .withLang("painless") - .withParams(Collections.singletonMap("newMessage", messageAfterUpdate)) - .withAbortOnVersionConflict(true) + .withScript("ctx._source['message'] = params['newMessage']").withLang("painless") + .withParams(Collections.singletonMap("newMessage", messageAfterUpdate)).withAbortOnVersionConflict(true) .build(); // when @@ -3619,7 +3626,7 @@ void shouldTrackTotalHitsToSpecificValue() { softly.assertAll(); } - @Test + @Test // DATAES-907 @DisplayName("should track total hits is off") void shouldTrackTotalHitsIsOff() { @@ -3642,6 +3649,39 @@ void shouldTrackTotalHitsIsOff() { softly.assertAll(); } + @Test // #725 + @DisplayName("should not return explanation when not requested") + void shouldNotReturnExplanationWhenNotRequested() { + + SampleEntity entity = SampleEntity.builder().id("42").message("a message with text").build(); + operations.save(entity); + Criteria criteria = new Criteria("message").contains("with"); + CriteriaQuery query = new CriteriaQuery(criteria); + + SearchHits searchHits = operations.search(query, SampleEntity.class); + + assertThat(searchHits.getTotalHits()).isEqualTo(1L); + Explanation explanation = searchHits.getSearchHit(0).getExplanation(); + assertThat(explanation).isNull(); + } + + @Test // #725 + @DisplayName("should return explanation when requested") + void shouldReturnExplanationWhenRequested() { + + SampleEntity entity = SampleEntity.builder().id("42").message("a message with text").build(); + operations.save(entity); + Criteria criteria = new Criteria("message").contains("with"); + CriteriaQuery query = new CriteriaQuery(criteria); + query.setExplain(true); + + SearchHits searchHits = operations.search(query, SampleEntity.class); + + assertThat(searchHits.getTotalHits()).isEqualTo(1L); + Explanation explanation = searchHits.getSearchHit(0).getExplanation(); + assertThat(explanation).isNotNull(); + } + @Data @NoArgsConstructor @AllArgsConstructor @@ -3836,5 +3876,4 @@ static class SampleJoinEntity { @JoinTypeRelation(parent = "question", children = { "answer" }) }) private JoinField myJoinField; @Field(type = Text) private String text; } - } diff --git a/src/test/java/org/springframework/data/elasticsearch/core/ReactiveElasticsearchTemplateIntegrationTests.java b/src/test/java/org/springframework/data/elasticsearch/core/ReactiveElasticsearchTemplateIntegrationTests.java index 6c18aff2a..d713adf9c 100644 --- a/src/test/java/org/springframework/data/elasticsearch/core/ReactiveElasticsearchTemplateIntegrationTests.java +++ b/src/test/java/org/springframework/data/elasticsearch/core/ReactiveElasticsearchTemplateIntegrationTests.java @@ -25,6 +25,7 @@ import lombok.Data; import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; +import org.springframework.data.elasticsearch.core.document.Explanation; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; @@ -1058,6 +1059,45 @@ void shouldBeAbleToProcessDateMathIndexNames() { .expectNext(true) // .verifyComplete(); // } + + @Test // #725 + @DisplayName("should not return explanation when not requested") + void shouldNotReturnExplanationWhenNotRequested() { + + ElasticsearchTemplateTests.SampleEntity entity = ElasticsearchTemplateTests.SampleEntity.builder().id("42").message("a message with text").build(); + template.save(entity).as(StepVerifier::create).expectNextCount(1).verifyComplete(); + + Criteria criteria = new Criteria("message").contains("with"); + CriteriaQuery query = new CriteriaQuery(criteria); + + template.search(query, ElasticsearchTemplateTests.SampleEntity.class) + .as(StepVerifier::create) + .consumeNextWith(searchHit -> { + Explanation explanation = searchHit.getExplanation(); + assertThat(explanation).isNull(); + }) + .verifyComplete(); + } + + @Test // #725 + @DisplayName("should return explanation when requested") + void shouldReturnExplanationWhenRequested() { + + ElasticsearchTemplateTests.SampleEntity entity = ElasticsearchTemplateTests.SampleEntity.builder().id("42").message("a message with text").build(); + template.save(entity).as(StepVerifier::create).expectNextCount(1).verifyComplete(); + + Criteria criteria = new Criteria("message").contains("with"); + CriteriaQuery query = new CriteriaQuery(criteria); + query.setExplain(true); + + template.search(query, ElasticsearchTemplateTests.SampleEntity.class) + .as(StepVerifier::create) + .consumeNextWith(searchHit -> { + Explanation explanation = searchHit.getExplanation(); + assertThat(explanation).isNotNull(); + }) + .verifyComplete(); + } // endregion // region Helper functions