diff --git a/src/main/java/org/springframework/data/elasticsearch/client/elc/ElasticsearchTemplate.java b/src/main/java/org/springframework/data/elasticsearch/client/elc/ElasticsearchTemplate.java index 89f346e7c..31c4686e2 100644 --- a/src/main/java/org/springframework/data/elasticsearch/client/elc/ElasticsearchTemplate.java +++ b/src/main/java/org/springframework/data/elasticsearch/client/elc/ElasticsearchTemplate.java @@ -29,6 +29,7 @@ import java.io.IOException; import java.time.Duration; import java.util.ArrayList; +import java.util.Collections; import java.util.HashMap; import java.util.Iterator; import java.util.List; @@ -72,6 +73,7 @@ * @author Peter-Josef Meisch * @author Hamid Rahimi * @author Illia Ulianov + * @author Haibo Liu * @since 4.4 */ public class ElasticsearchTemplate extends AbstractElasticsearchTemplate { @@ -437,13 +439,10 @@ public List> multiSearch(List queries, Class< Assert.notNull(queries, "queries must not be null"); Assert.notNull(clazz, "clazz must not be null"); - List multiSearchQueryParameters = new ArrayList<>(queries.size()); - for (Query query : queries) { - multiSearchQueryParameters.add(new MultiSearchQueryParameter(query, clazz, getIndexCoordinatesFor(clazz))); - } - + int size = queries.size(); // noinspection unchecked - return doMultiSearch(multiSearchQueryParameters).stream().map(searchHits -> (SearchHits) searchHits) + return multiSearch(queries, Collections.nCopies(size, clazz), Collections.nCopies(size, index)) + .stream().map(searchHits -> (SearchHits) searchHits) .collect(Collectors.toList()); } @@ -454,14 +453,7 @@ public List> multiSearch(List queries, List multiSearchQueryParameters = new ArrayList<>(queries.size()); - Iterator> it = classes.iterator(); - for (Query query : queries) { - Class clazz = it.next(); - multiSearchQueryParameters.add(new MultiSearchQueryParameter(query, clazz, getIndexCoordinatesFor(clazz))); - } - - return doMultiSearch(multiSearchQueryParameters); + return multiSearch(queries, classes, classes.stream().map(this::getIndexCoordinatesFor).toList()); } @Override @@ -473,14 +465,7 @@ public List> multiSearch(List queries, List multiSearchQueryParameters = new ArrayList<>(queries.size()); - Iterator> it = classes.iterator(); - for (Query query : queries) { - Class clazz = it.next(); - multiSearchQueryParameters.add(new MultiSearchQueryParameter(query, clazz, index)); - } - - return doMultiSearch(multiSearchQueryParameters); + return multiSearch(queries, classes, Collections.nCopies(queries.size(), index)); } @Override @@ -497,16 +482,49 @@ public List> multiSearch(List queries, List> it = classes.iterator(); Iterator indexesIt = indexes.iterator(); + Assert.isTrue(!queries.isEmpty(), "queries should have at least 1 query"); + boolean isSearchTemplateQuery = queries.get(0) instanceof SearchTemplateQuery; + for (Query query : queries) { + Assert.isTrue((query instanceof SearchTemplateQuery) == isSearchTemplateQuery, + "SearchTemplateQuery can't be mixed with other types of query in multiple search"); + Class clazz = it.next(); IndexCoordinates index = indexesIt.next(); multiSearchQueryParameters.add(new MultiSearchQueryParameter(query, clazz, index)); } - return doMultiSearch(multiSearchQueryParameters); + return multiSearch(multiSearchQueryParameters, isSearchTemplateQuery); + } + + private List> multiSearch(List multiSearchQueryParameters, + boolean isSearchTemplateQuery) { + return isSearchTemplateQuery ? + doMultiTemplateSearch(multiSearchQueryParameters.stream() + .map(p -> new MultiSearchTemplateQueryParameter((SearchTemplateQuery) p.query, p.clazz, p.index)) + .toList()) + : doMultiSearch(multiSearchQueryParameters); + } + + private List> doMultiTemplateSearch(List mSearchTemplateQueryParameters) { + MsearchTemplateRequest request = requestConverter.searchMsearchTemplateRequest(mSearchTemplateQueryParameters, + routingResolver.getRouting()); + + MsearchTemplateResponse response = execute(client -> client.msearchTemplate(request, EntityAsMap.class)); + List> responseItems = response.responses(); + + Assert.isTrue(mSearchTemplateQueryParameters.size() == responseItems.size(), + "number of response items does not match number of requests"); + + int size = mSearchTemplateQueryParameters.size(); + List> classes = mSearchTemplateQueryParameters + .stream().map(MultiSearchTemplateQueryParameter::clazz).collect(Collectors.toList()); + List indices = mSearchTemplateQueryParameters + .stream().map(MultiSearchTemplateQueryParameter::index).collect(Collectors.toList()); + + return getSearchHitsFromMsearchResponse(size, classes, indices, responseItems); } - @SuppressWarnings({ "unchecked", "rawtypes" }) private List> doMultiSearch(List multiSearchQueryParameters) { MsearchRequest request = requestConverter.searchMsearchRequest(multiSearchQueryParameters, @@ -518,22 +536,37 @@ private List> doMultiSearch(List multiS Assert.isTrue(multiSearchQueryParameters.size() == responseItems.size(), "number of response items does not match number of requests"); - List> searchHitsList = new ArrayList<>(multiSearchQueryParameters.size()); + int size = multiSearchQueryParameters.size(); + List> classes = multiSearchQueryParameters + .stream().map(MultiSearchQueryParameter::clazz).collect(Collectors.toList()); + List indices = multiSearchQueryParameters + .stream().map(MultiSearchQueryParameter::index).collect(Collectors.toList()); - Iterator queryIterator = multiSearchQueryParameters.iterator(); + return getSearchHitsFromMsearchResponse(size, classes, indices, responseItems); + } + + /** + * {@link MsearchResponse} and {@link MsearchTemplateResponse} share the same {@link MultiSearchResponseItem} + */ + @SuppressWarnings({ "unchecked", "rawtypes" }) + private List> getSearchHitsFromMsearchResponse(int size, List> classes, + List indices, List> responseItems) { + List> searchHitsList = new ArrayList<>(size); + Iterator> clazzIter = classes.iterator(); + Iterator indexIter = indices.iterator(); Iterator> responseIterator = responseItems.iterator(); - while (queryIterator.hasNext()) { - MultiSearchQueryParameter queryParameter = queryIterator.next(); + while (clazzIter.hasNext() && indexIter.hasNext()) { MultiSearchResponseItem responseItem = responseIterator.next(); if (responseItem.isResult()) { - Class clazz = queryParameter.clazz; + Class clazz = clazzIter.next(); + IndexCoordinates index = indexIter.next(); ReadDocumentCallback documentCallback = new ReadDocumentCallback<>(elasticsearchConverter, clazz, - queryParameter.index); + index); SearchDocumentResponseCallback> callback = new ReadSearchDocumentResponseCallback<>(clazz, - queryParameter.index); + index); SearchHits searchHits = callback.doWith( SearchDocumentResponseBuilder.from(responseItem.result(), getEntityCreator(documentCallback), jsonpMapper)); @@ -541,8 +574,8 @@ private List> doMultiSearch(List multiS searchHitsList.add(searchHits); } else { if (LOGGER.isWarnEnabled()) { - LOGGER - .warn(String.format("multisearch responsecontains failure: {}", responseItem.failure().error().reason())); + LOGGER.warn(String.format("multisearch response contains failure: %s", + responseItem.failure().error().reason())); } } } @@ -556,6 +589,12 @@ private List> doMultiSearch(List multiS record MultiSearchQueryParameter(Query query, Class clazz, IndexCoordinates index) { } + /** + * value class combining the information needed for a single query in a template multisearch request. + */ + record MultiSearchTemplateQueryParameter(SearchTemplateQuery query, Class clazz, IndexCoordinates index) { + } + @Override public String openPointInTime(IndexCoordinates index, Duration keepAlive, Boolean ignoreUnavailable) { 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 607929696..f9fea8588 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 @@ -44,6 +44,7 @@ import co.elastic.clients.elasticsearch.core.bulk.UpdateOperation; import co.elastic.clients.elasticsearch.core.mget.MultiGetOperation; import co.elastic.clients.elasticsearch.core.msearch.MultisearchBody; +import co.elastic.clients.elasticsearch.core.msearch.MultisearchHeader; import co.elastic.clients.elasticsearch.core.search.Highlight; import co.elastic.clients.elasticsearch.core.search.Rescore; import co.elastic.clients.elasticsearch.core.search.SourceConfig; @@ -54,6 +55,7 @@ import co.elastic.clients.json.JsonData; import co.elastic.clients.json.JsonpDeserializer; import co.elastic.clients.json.JsonpMapper; +import co.elastic.clients.util.ObjectBuilder; import jakarta.json.stream.JsonParser; import java.io.ByteArrayInputStream; @@ -66,6 +68,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.function.Function; import java.util.stream.Collectors; @@ -397,10 +400,7 @@ public co.elastic.clients.elasticsearch.indices.PutTemplateRequest indicesPutTem .order(putTemplateRequest.getOrder()); if (putTemplateRequest.getSettings() != null) { - Function, String> keyMapper = Map.Entry::getKey; - Function, JsonData> valueMapper = entry -> JsonData.of(entry.getValue(), jsonpMapper); - Map settings = putTemplateRequest.getSettings().entrySet().stream() - .collect(Collectors.toMap(keyMapper, valueMapper)); + Map settings = getTemplateParams(putTemplateRequest.getSettings().entrySet()); builder.settings(settings); } @@ -1146,6 +1146,36 @@ public SearchRequest searchRequest(Query query, @Nullable String routing, @N return builder.build(); } + public MsearchTemplateRequest searchMsearchTemplateRequest( + List multiSearchTemplateQueryParameters, + @Nullable String routing) { + + // basically the same stuff as in template search + return MsearchTemplateRequest.of(mtrb -> { + multiSearchTemplateQueryParameters.forEach(param -> { + var query = param.query(); + mtrb.searchTemplates(stb -> stb + .header(msearchHeaderBuilder(query, param.index(), routing)) + .body(bb -> { + bb // + .explain(query.getExplain()) // + .id(query.getId()) // + .source(query.getSource()) // + ; + + if (!CollectionUtils.isEmpty(query.getParams())) { + Map params = getTemplateParams(query.getParams().entrySet()); + bb.params(params); + } + + return bb; + }) + ); + }); + return mtrb; + }); + } + public MsearchRequest searchMsearchRequest( List multiSearchQueryParameters, @Nullable String routing) { @@ -1157,28 +1187,7 @@ public MsearchRequest searchMsearchRequest( var query = param.query(); mrb.searches(sb -> sb // - .header(h -> { - var searchType = (query instanceof NativeQuery nativeQuery && nativeQuery.getKnnQuery() != null) ? null - : searchType(query.getSearchType()); - - h // - .index(Arrays.asList(param.index().getIndexNames())) // - .searchType(searchType) // - .requestCache(query.getRequestCache()) // - ; - - if (StringUtils.hasText(query.getRoute())) { - h.routing(query.getRoute()); - } else if (StringUtils.hasText(routing)) { - h.routing(routing); - } - - if (query.getPreference() != null) { - h.preference(query.getPreference()); - } - - return h; - }) // + .header(msearchHeaderBuilder(query, param.index(), routing)) // .body(bb -> { bb // .query(getQuery(query, param.clazz()))// @@ -1284,6 +1293,35 @@ public MsearchRequest searchMsearchRequest( }); } + /** + * {@link MsearchRequest} and {@link MsearchTemplateRequest} share the same {@link MultisearchHeader} + */ + private Function> msearchHeaderBuilder(Query query, + IndexCoordinates index, @Nullable String routing) { + return h -> { + var searchType = (query instanceof NativeQuery nativeQuery && nativeQuery.getKnnQuery() != null) ? null + : searchType(query.getSearchType()); + + h // + .index(Arrays.asList(index.getIndexNames())) // + .searchType(searchType) // + .requestCache(query.getRequestCache()) // + ; + + if (StringUtils.hasText(query.getRoute())) { + h.routing(query.getRoute()); + } else if (StringUtils.hasText(routing)) { + h.routing(routing); + } + + if (query.getPreference() != null) { + h.preference(query.getPreference()); + } + + return h; + }; + } + private void prepareSearchRequest(Query query, @Nullable String routing, @Nullable Class clazz, IndexCoordinates indexCoordinates, SearchRequest.Builder builder, boolean forCount, boolean forBatchedSearch) { @@ -1770,7 +1808,8 @@ public SearchTemplateRequest searchTemplate(SearchTemplateQuery query, @Nullable .id(query.getId()) // .index(Arrays.asList(index.getIndexNames())) // .preference(query.getPreference()) // - .searchType(searchType(query.getSearchType())).source(query.getSource()) // + .searchType(searchType(query.getSearchType())) // + .source(query.getSource()) // ; if (query.getRoute() != null) { @@ -1789,10 +1828,7 @@ public SearchTemplateRequest searchTemplate(SearchTemplateQuery query, @Nullable } if (!CollectionUtils.isEmpty(query.getParams())) { - Function, String> keyMapper = Map.Entry::getKey; - Function, JsonData> valueMapper = entry -> JsonData.of(entry.getValue(), jsonpMapper); - Map params = query.getParams().entrySet().stream() - .collect(Collectors.toMap(keyMapper, valueMapper)); + Map params = getTemplateParams(query.getParams().entrySet()); builder.params(params); } @@ -1800,6 +1836,14 @@ public SearchTemplateRequest searchTemplate(SearchTemplateQuery query, @Nullable }); } + @NotNull + private Map getTemplateParams(Set> query) { + Function, String> keyMapper = Map.Entry::getKey; + Function, JsonData> valueMapper = entry -> JsonData.of(entry.getValue(), jsonpMapper); + return query.stream() + .collect(Collectors.toMap(keyMapper, valueMapper)); + } + // endregion public PutScriptRequest scriptPut(Script script) { diff --git a/src/test/java/org/springframework/data/elasticsearch/core/SearchTemplateIntegrationTests.java b/src/test/java/org/springframework/data/elasticsearch/core/SearchTemplateIntegrationTests.java index 0284ee2a4..f363f9420 100644 --- a/src/test/java/org/springframework/data/elasticsearch/core/SearchTemplateIntegrationTests.java +++ b/src/test/java/org/springframework/data/elasticsearch/core/SearchTemplateIntegrationTests.java @@ -18,6 +18,7 @@ import static org.assertj.core.api.Assertions.*; import static org.skyscreamer.jsonassert.JSONAssert.*; +import java.util.List; import java.util.Map; import org.json.JSONException; @@ -42,11 +43,12 @@ * Integration tests search template API. * * @author Peter-Josef Meisch + * @author Haibo Liu */ @SpringIntegrationTest public abstract class SearchTemplateIntegrationTests { - private static final String SCRIPT = """ + private static final String SEARCH_FIRSTNAME = """ { "query": { "bool": { @@ -63,21 +65,57 @@ public abstract class SearchTemplateIntegrationTests { "size": 100 } """; - private Script script = Script.builder() // - .withId("testScript") // + + private static final String SEARCH_LASTNAME = """ + { + "query": { + "bool": { + "must": [ + { + "match": { + "lastName": "{{lastName}}" + } + } + ] + } + }, + "from": 0, + "size": 100 + } + """; + + private static final Script SCRIPT_SEARCH_FIRSTNAME = Script.builder() // + .withId("searchFirstName") // + .withLanguage("mustache") // + .withSource(SEARCH_FIRSTNAME) // + .build(); + + private static final Script SCRIPT_SEARCH_LASTNAME = Script.builder() // + .withId("searchLastName") // .withLanguage("mustache") // - .withSource(SCRIPT) // + .withSource(SEARCH_LASTNAME) // .build(); @Autowired ElasticsearchOperations operations; @Autowired IndexNameProvider indexNameProvider; - @Nullable IndexOperations indexOperations; + IndexOperations personIndexOperations, studentIndexOperations; @BeforeEach void setUp() { indexNameProvider.increment(); - indexOperations = operations.indexOps(Person.class); - indexOperations.createWithMapping(); + personIndexOperations = operations.indexOps(Person.class); + personIndexOperations.createWithMapping(); + studentIndexOperations = operations.indexOps(Student.class); + studentIndexOperations.createWithMapping(); + + operations.save( // + new Person("1", "John", "Smith"), // + new Person("2", "Willy", "Smith"), // + new Person("3", "John", "Myers")); + + operations.save( + new Student("1", "Joey", "Dunlop"), // + new Student("2", "Michael", "Dunlop")); } @Test @@ -89,41 +127,35 @@ void cleanup() { @Test // #1891 @DisplayName("should store, retrieve and delete template script") void shouldStoreAndRetrieveAndDeleteTemplateScript() throws JSONException { - // we do all in this test because scripts aren't stored in an index but in the cluster and we need to clenaup. - var success = operations.putScript(script); + var success = operations.putScript(SCRIPT_SEARCH_FIRSTNAME); assertThat(success).isTrue(); - var savedScript = operations.getScript(script.id()); + var savedScript = operations.getScript(SCRIPT_SEARCH_FIRSTNAME.id()); assertThat(savedScript).isNotNull(); - assertThat(savedScript.id()).isEqualTo(script.id()); - assertThat(savedScript.language()).isEqualTo(script.language()); - assertEquals(savedScript.source(), script.source(), false); + assertThat(savedScript.id()).isEqualTo(SCRIPT_SEARCH_FIRSTNAME.id()); + assertThat(savedScript.language()).isEqualTo(SCRIPT_SEARCH_FIRSTNAME.language()); + assertEquals(savedScript.source(), SCRIPT_SEARCH_FIRSTNAME.source(), false); - success = operations.deleteScript(script.id()); + success = operations.deleteScript(SCRIPT_SEARCH_FIRSTNAME.id()); assertThat(success).isTrue(); - savedScript = operations.getScript(script.id()); + savedScript = operations.getScript(SCRIPT_SEARCH_FIRSTNAME.id()); assertThat(savedScript).isNull(); - assertThatThrownBy(() -> operations.deleteScript(script.id())) // + assertThatThrownBy(() -> operations.deleteScript(SCRIPT_SEARCH_FIRSTNAME.id())) // .isInstanceOf(ResourceNotFoundException.class); } @Test // #1891 @DisplayName("should search with template") void shouldSearchWithTemplate() { - - var success = operations.putScript(script); + var success = operations.putScript(SCRIPT_SEARCH_FIRSTNAME); assertThat(success).isTrue(); - operations.save( // - new Person("1", "John", "Smith"), // - new Person("2", "Willy", "Smith"), // - new Person("3", "John", "Myers")); var query = SearchTemplateQuery.builder() // - .withId(script.id()) // + .withId(SCRIPT_SEARCH_FIRSTNAME.id()) // .withParams(Map.of("firstName", "John")) // .build(); @@ -131,15 +163,169 @@ void shouldSearchWithTemplate() { assertThat(searchHits.getTotalHits()).isEqualTo(2); - success = operations.deleteScript(script.id()); + success = operations.deleteScript(SCRIPT_SEARCH_FIRSTNAME.id()); assertThat(success).isTrue(); } - @Document(indexName = "#{@indexNameProvider.indexName()}") + @Test // #2704 + @DisplayName("should search with template multisearch") + void shouldSearchWithTemplateMultiSearch() { + var success = operations.putScript(SCRIPT_SEARCH_FIRSTNAME); + assertThat(success).isTrue(); + + var q1 = SearchTemplateQuery.builder() // + .withId(SCRIPT_SEARCH_FIRSTNAME.id()) // + .withParams(Map.of("firstName", "John")) // + .build(); + var q2 = SearchTemplateQuery.builder() // + .withId(SCRIPT_SEARCH_FIRSTNAME.id()) // + .withParams(Map.of("firstName", "Willy")) // + .build(); + + var multiSearchHits = operations.multiSearch(List.of(q1, q2), Person.class); + + assertThat(multiSearchHits.size()).isEqualTo(2); + assertThat(multiSearchHits.get(0).getTotalHits()).isEqualTo(2); + assertThat(multiSearchHits.get(1).getTotalHits()).isEqualTo(1); + + assertThat(multiSearchHits.get(0).getSearchHits()) + .extracting(SearchHit::getContent) + .extracting(Person::lastName) + .contains("Smith", "Myers"); + assertThat(multiSearchHits.get(1).getSearchHits()) + .extracting(SearchHit::getContent) + .extracting(Person::lastName) + .containsExactly("Smith"); + + success = operations.deleteScript(SCRIPT_SEARCH_FIRSTNAME.id()); + assertThat(success).isTrue(); + } + + @Test // #2704 + @DisplayName("should search with template multisearch including different scripts") + void shouldSearchWithTemplateMultiSearchIncludingDifferentScripts() { + assertThat(operations.putScript(SCRIPT_SEARCH_FIRSTNAME)).isTrue(); + assertThat(operations.putScript(SCRIPT_SEARCH_LASTNAME)).isTrue(); + + var q1 = SearchTemplateQuery.builder() // + .withId(SCRIPT_SEARCH_FIRSTNAME.id()) // + .withParams(Map.of("firstName", "John")) // + .build(); + var q2 = SearchTemplateQuery.builder() // + .withId(SCRIPT_SEARCH_LASTNAME.id()) // + .withParams(Map.of("lastName", "smith")) // + .build(); + + var multiSearchHits = operations.multiSearch(List.of(q1, q2), Person.class); + + assertThat(multiSearchHits.size()).isEqualTo(2); + assertThat(multiSearchHits.get(0).getTotalHits()).isEqualTo(2); + assertThat(multiSearchHits.get(1).getTotalHits()).isEqualTo(2); + + assertThat(multiSearchHits.get(0).getSearchHits()) + .extracting(SearchHit::getContent) + .extracting(Person::lastName) + .contains("Smith", "Myers"); + assertThat(multiSearchHits.get(1).getSearchHits()) + .extracting(SearchHit::getContent) + .extracting(Person::firstName) + .contains("John", "Willy"); + + assertThat(operations.deleteScript(SCRIPT_SEARCH_FIRSTNAME.id())).isTrue(); + assertThat(operations.deleteScript(SCRIPT_SEARCH_LASTNAME.id())).isTrue(); + } + + @Test // #2704 + @DisplayName("should search with template multisearch with multiple classes") + void shouldSearchWithTemplateMultiSearchWithMultipleClasses() { + assertThat(operations.putScript(SCRIPT_SEARCH_FIRSTNAME)).isTrue(); + assertThat(operations.putScript(SCRIPT_SEARCH_LASTNAME)).isTrue(); + + var q1 = SearchTemplateQuery.builder() // + .withId(SCRIPT_SEARCH_FIRSTNAME.id()) // + .withParams(Map.of("firstName", "John")) // + .build(); + var q2 = SearchTemplateQuery.builder() // + .withId(SCRIPT_SEARCH_FIRSTNAME.id()) // + .withParams(Map.of("firstName", "Joey")) // + .build(); + + // search with multiple classes + var multiSearchHits = operations.multiSearch(List.of(q1, q2), List.of(Person.class, Student.class)); + + assertThat(multiSearchHits.size()).isEqualTo(2); + assertThat(multiSearchHits.get(0).getTotalHits()).isEqualTo(2); + assertThat(multiSearchHits.get(1).getTotalHits()).isEqualTo(1); + + assertThat(multiSearchHits.get(0).getSearchHits()) + // type casting is needed here + .extracting(hits -> (Person) hits.getContent()) + .extracting(Person::lastName) + .contains("Smith", "Myers"); + assertThat(multiSearchHits.get(1).getSearchHits()) + // type casting is needed here + .extracting(hits -> (Student) hits.getContent()) + .extracting(Student::lastName) + .containsExactly("Dunlop"); + + assertThat(operations.deleteScript(SCRIPT_SEARCH_FIRSTNAME.id())).isTrue(); + assertThat(operations.deleteScript(SCRIPT_SEARCH_LASTNAME.id())).isTrue(); + } + + @Test // #2704 + @DisplayName("should search with template multisearch with multiple index coordinates") + void shouldSearchWithTemplateMultiSearchWithMultipleIndexCoordinates() { + assertThat(operations.putScript(SCRIPT_SEARCH_FIRSTNAME)).isTrue(); + assertThat(operations.putScript(SCRIPT_SEARCH_LASTNAME)).isTrue(); + + var q1 = SearchTemplateQuery.builder() // + .withId(SCRIPT_SEARCH_FIRSTNAME.id()) // + .withParams(Map.of("firstName", "John")) // + .build(); + var q2 = SearchTemplateQuery.builder() // + .withId(SCRIPT_SEARCH_LASTNAME.id()) // + .withParams(Map.of("lastName", "Dunlop")) // + .build(); + + // search with multiple index coordinates + var multiSearchHits = operations.multiSearch( + List.of(q1, q2), + List.of(Person.class, Student.class), + List.of(IndexCoordinates.of(indexNameProvider.indexName() + "-person"), + IndexCoordinates.of(indexNameProvider.indexName() + "-student"))); + + assertThat(multiSearchHits.size()).isEqualTo(2); + assertThat(multiSearchHits.get(0).getTotalHits()).isEqualTo(2); + assertThat(multiSearchHits.get(1).getTotalHits()).isEqualTo(2); + + assertThat(multiSearchHits.get(0).getSearchHits()) + // type casting is needed here + .extracting(hits -> (Person) hits.getContent()) + .extracting(Person::lastName) + .contains("Smith", "Myers"); + assertThat(multiSearchHits.get(1).getSearchHits()) + // type casting is needed here + .extracting(hits -> (Student) hits.getContent()) + .extracting(Student::firstName) + .contains("Joey", "Michael"); + + assertThat(operations.deleteScript(SCRIPT_SEARCH_FIRSTNAME.id())).isTrue(); + assertThat(operations.deleteScript(SCRIPT_SEARCH_LASTNAME.id())).isTrue(); + } + + @Document(indexName = "#{@indexNameProvider.indexName()}-person") record Person( // @Nullable @Id String id, // @Field(type = FieldType.Text) String firstName, // @Field(type = FieldType.Text) String lastName // ) { } + + @Document(indexName = "#{@indexNameProvider.indexName()}-student") + record Student( // + @Nullable @Id String id, // + @Field(type = FieldType.Text) String firstName, // + @Field(type = FieldType.Text) String lastName // + ) { + } }