diff --git a/data-model/src/main/java/org/alfresco/service/cmr/search/SearchParameters.java b/data-model/src/main/java/org/alfresco/service/cmr/search/SearchParameters.java index 0cc4350113..9524e72325 100644 --- a/data-model/src/main/java/org/alfresco/service/cmr/search/SearchParameters.java +++ b/data-model/src/main/java/org/alfresco/service/cmr/search/SearchParameters.java @@ -44,7 +44,7 @@ import org.alfresco.service.cmr.repository.StoreRef; import org.alfresco.service.namespace.NamespaceService; /** - * This class provides parameters to define a search. TODO - paging of results page number and page size - paging isolation - REPEATABLE READ, READ COMMITTED, may SEE ONCE tracking node refs in previous result sets - how long repeatable read may be held - limit by the number of permission evaluations + * This class provides parameters to define a search. * * @author Andy Hind */ @@ -198,6 +198,8 @@ public class SearchParameters implements BasicSearchParameters private boolean trackScore = true; + private String searchAfterToken; + /** * Default constructor */ @@ -250,6 +252,7 @@ public class SearchParameters implements BasicSearchParameters sp.timezone = this.timezone; sp.trackTotalHits = this.trackTotalHits; sp.trackScore = this.trackScore; + sp.searchAfterToken = this.searchAfterToken; return sp; } @@ -600,6 +603,16 @@ public class SearchParameters implements BasicSearchParameters this.limit = limit; } + public String getSearchAfterToken() + { + return searchAfterToken; + } + + public void setSearchAfterToken(String searchAfterToken) + { + this.searchAfterToken = searchAfterToken; + } + /** * The way in which multilingual fields are treated durig a search. By default, only the specified locale is used and it must be an exact match. * @@ -1197,6 +1210,7 @@ public class SearchParameters implements BasicSearchParameters result = prime * result + ((ranges == null) ? 0 : ranges.hashCode()); result = prime * result + ((searchTerm == null) ? 0 : searchTerm.hashCode()); result = prime * result + (spellCheck ? 1231 : 1237); + result = prime * result + ((searchAfterToken == null) ? 0 : searchAfterToken.hashCode()); return result; } @@ -1359,6 +1373,17 @@ public class SearchParameters implements BasicSearchParameters return false; if (spellCheck != other.spellCheck) return false; + if (searchAfterToken == null) + { + if (other.searchAfterToken != null) + { + return false; + } + } + else if (!searchAfterToken.equals(other.searchAfterToken)) + { + return false; + } return true; } @@ -1401,7 +1426,8 @@ public class SearchParameters implements BasicSearchParameters .append(", interval=").append(this.interval) .append(", range=").append(this.ranges) .append(", timezone=").append(this.timezone) - .append(", spellCheck=").append(this.spellCheck).append("]"); + .append(", spellCheck=").append(this.spellCheck) + .append(", searchAfterToken=").append(this.searchAfterToken).append("]"); return builder.toString(); } diff --git a/pom.xml b/pom.xml index 015c15e367..caf32df980 100644 --- a/pom.xml +++ b/pom.xml @@ -98,7 +98,7 @@ 4.2.2 - 2.21.0 + 2.26.0 9.7.0 4.1.3 1.0.73 diff --git a/remote-api/src/main/java/org/alfresco/rest/api/search/SearchApiWebscript.java b/remote-api/src/main/java/org/alfresco/rest/api/search/SearchApiWebscript.java index 856a52f9c4..ab3cfbba9e 100644 --- a/remote-api/src/main/java/org/alfresco/rest/api/search/SearchApiWebscript.java +++ b/remote-api/src/main/java/org/alfresco/rest/api/search/SearchApiWebscript.java @@ -85,7 +85,7 @@ public class SearchApiWebscript extends AbstractWebScript implements RecognizedP { try { - // Turn JSON into a Java object respresentation + // Turn JSON into a Java object representation SearchQuery searchQuery = extractJsonContent(webScriptRequest, assistant.getJsonHelper(), SearchQuery.class); // Parse the parameters diff --git a/remote-api/src/main/java/org/alfresco/rest/api/search/impl/ResultMapper.java b/remote-api/src/main/java/org/alfresco/rest/api/search/impl/ResultMapper.java index d6ff69ce4f..588499cf7e 100644 --- a/remote-api/src/main/java/org/alfresco/rest/api/search/impl/ResultMapper.java +++ b/remote-api/src/main/java/org/alfresco/rest/api/search/impl/ResultMapper.java @@ -231,7 +231,11 @@ public class ResultMapper .map(resultSet -> toSearchContext(resultSet, searchRequestContext, searchQuery)) .orElse(null); - return CollectionWithPagingInfo.asPaged(params.getPaging(), noderesults, results.hasMore(), setTotal(results), null, context); + String nextSearchAfterToken = toSearchEngineResultSet(results) + .map(SearchEngineResultSet::getNextSearchAfterToken) + .orElse(null); + + return CollectionWithPagingInfo.asPaged(params.getPaging(), noderesults, results.hasMore(), setTotal(results), null, context, nextSearchAfterToken); } /** diff --git a/remote-api/src/main/java/org/alfresco/rest/api/search/impl/SearchMapper.java b/remote-api/src/main/java/org/alfresco/rest/api/search/impl/SearchMapper.java index 7bafe68bc2..271e57147a 100644 --- a/remote-api/src/main/java/org/alfresco/rest/api/search/impl/SearchMapper.java +++ b/remote-api/src/main/java/org/alfresco/rest/api/search/impl/SearchMapper.java @@ -199,6 +199,10 @@ public class SearchMapper sp.setLimitBy(LimitBy.FINAL_SIZE); sp.setLimit(paging.getMaxItems()); sp.setSkipCount(paging.getSkipCount()); + if (paging.getSearchAfterToken() != null) + { + sp.setSearchAfterToken(paging.getSearchAfterToken()); + } } } diff --git a/remote-api/src/main/java/org/alfresco/rest/framework/jacksonextensions/SerializerOfCollectionWithPaging.java b/remote-api/src/main/java/org/alfresco/rest/framework/jacksonextensions/SerializerOfCollectionWithPaging.java index ce9a933d2e..819f746f4b 100644 --- a/remote-api/src/main/java/org/alfresco/rest/framework/jacksonextensions/SerializerOfCollectionWithPaging.java +++ b/remote-api/src/main/java/org/alfresco/rest/framework/jacksonextensions/SerializerOfCollectionWithPaging.java @@ -103,6 +103,10 @@ public class SerializerOfCollectionWithPaging extends StdSerializer * The search context for the collection */ SearchContext getContext(); + + /** + * The search_after token for fetching the next page, or null if not applicable. + */ + default String getNextSearchAfterToken() + { + return null; + } } diff --git a/remote-api/src/main/java/org/alfresco/rest/framework/resource/parameters/CollectionWithPagingInfo.java b/remote-api/src/main/java/org/alfresco/rest/framework/resource/parameters/CollectionWithPagingInfo.java index 218ab6a2e3..1120b2e221 100644 --- a/remote-api/src/main/java/org/alfresco/rest/framework/resource/parameters/CollectionWithPagingInfo.java +++ b/remote-api/src/main/java/org/alfresco/rest/framework/resource/parameters/CollectionWithPagingInfo.java @@ -50,6 +50,7 @@ public class CollectionWithPagingInfo implements SerializablePagedCollection< private final Paging paging; private final Object sourceEntity; private final SearchContext context; + private final String nextSearchAfterToken; /** * Constructs a new CollectionWithPagingInfo. @@ -64,6 +65,11 @@ public class CollectionWithPagingInfo implements SerializablePagedCollection< * - The total number of items available. */ protected CollectionWithPagingInfo(Collection collection, Paging paging, boolean hasMoreItems, Integer totalItems, Object sourceEntity, SearchContext context) + { + this(collection, paging, hasMoreItems, totalItems, sourceEntity, context, null); + } + + protected CollectionWithPagingInfo(Collection collection, Paging paging, boolean hasMoreItems, Integer totalItems, Object sourceEntity, SearchContext context, String nextSearchAfterToken) { super(); this.hasMoreItems = hasMoreItems; @@ -81,6 +87,7 @@ public class CollectionWithPagingInfo implements SerializablePagedCollection< } this.sourceEntity = sourceEntity; this.context = context; + this.nextSearchAfterToken = nextSearchAfterToken; } /** @@ -93,7 +100,7 @@ public class CollectionWithPagingInfo implements SerializablePagedCollection< public static CollectionWithPagingInfo from(SerializablePagedCollection pagedCollection) { return new CollectionWithPagingInfo<>(pagedCollection.getCollection(), pagedCollection.getPaging(), pagedCollection.hasMoreItems(), pagedCollection.getTotalItems(), - pagedCollection.getSourceEntity(), pagedCollection.getContext()); + pagedCollection.getSourceEntity(), pagedCollection.getContext(), pagedCollection.getNextSearchAfterToken()); } /** @@ -143,7 +150,7 @@ public class CollectionWithPagingInfo implements SerializablePagedCollection< } /** - * Constructs a new CollectionWithPagingInfo. Not for public use. + * Constructs a new CollectionWithPagingInfo. * * @param paging * - Paging request info @@ -163,7 +170,7 @@ public class CollectionWithPagingInfo implements SerializablePagedCollection< } /** - * Constructs a new CollectionWithPagingInfo. Not for public use. + * Constructs a new CollectionWithPagingInfo. * * @param paging * - Paging request info @@ -184,6 +191,30 @@ public class CollectionWithPagingInfo implements SerializablePagedCollection< return new CollectionWithPagingInfo(aCollection, paging, hasMoreItems, totalItems, sourceEntity, context); } + /** + * Constructs a new CollectionWithPagingInfo carrying a search_after token. + * + * @param paging + * - Paging request info + * @param aCollection + * - the collection that needs to be paged. + * @param hasMoreItems + * - Are there more items after this Collection? + * @param totalItems + * - The total number of items available. + * @param sourceEntity + * - The parent/source entity responsible for the collection + * @param context + * - The search context + * @param nextSearchAfterToken + * - The search_after token for fetching the next page + * @return CollectionWithPagingInfo + */ + public static CollectionWithPagingInfo asPaged(Paging paging, Collection aCollection, boolean hasMoreItems, Integer totalItems, Object sourceEntity, SearchContext context, String nextSearchAfterToken) + { + return new CollectionWithPagingInfo<>(aCollection, paging, hasMoreItems, totalItems, sourceEntity, context, nextSearchAfterToken); + } + /** * Returns the Collection object * @@ -240,4 +271,10 @@ public class CollectionWithPagingInfo implements SerializablePagedCollection< return context; } + @Override + public String getNextSearchAfterToken() + { + return nextSearchAfterToken; + } + } diff --git a/remote-api/src/main/java/org/alfresco/rest/framework/resource/parameters/Paging.java b/remote-api/src/main/java/org/alfresco/rest/framework/resource/parameters/Paging.java index f297945f1b..082224ef81 100644 --- a/remote-api/src/main/java/org/alfresco/rest/framework/resource/parameters/Paging.java +++ b/remote-api/src/main/java/org/alfresco/rest/framework/resource/parameters/Paging.java @@ -27,6 +27,8 @@ package org.alfresco.rest.framework.resource.parameters; import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.deser.std.StringDeserializer; import org.alfresco.rest.framework.core.exceptions.InvalidArgumentException; @@ -46,8 +48,9 @@ public class Paging private final int skipCount; private final int maxItems; + private final String searchAfterToken; - private Paging(int skipCount, int maxItems) + private Paging(int skipCount, int maxItems, String searchAfterToken) { super(); if (skipCount < 0) @@ -60,6 +63,7 @@ public class Paging } this.skipCount = skipCount; this.maxItems = maxItems; + this.searchAfterToken = searchAfterToken; } /** @@ -82,21 +86,39 @@ public class Paging return this.maxItems; } - @JsonCreator - public static Paging valueOf(@JsonProperty("skipCount") int skipCount, @JsonProperty("maxItems") int maxItems) + /** + * The opaque search_after cursor for the next page, or null if not using cursor-based paging. + * + * @return String + */ + public String getSearchAfterToken() { - return new Paging(skipCount, maxItems); + return this.searchAfterToken; + } + + public static Paging valueOf(int skipCount, int maxItems) + { + return new Paging(skipCount, maxItems, null); + } + + @JsonCreator + public static Paging valueOf(@JsonProperty("skipCount") int skipCount, @JsonProperty("maxItems") int maxItems, + @JsonProperty("searchAfterToken") @JsonDeserialize(using = StringDeserializer.class) String searchAfterToken) + { + return new Paging(skipCount, maxItems, searchAfterToken); } @Override public String toString() { StringBuilder builder = new StringBuilder(); - builder.append("Paging [skipCount="); - builder.append(this.skipCount); - builder.append(", maxItems="); - builder.append(this.maxItems); - builder.append("]"); + builder.append("Paging [skipCount=") + .append(this.skipCount) + .append(", maxItems=") + .append(this.maxItems) + .append(", searchAfterToken=") + .append(this.searchAfterToken) + .append("]"); return builder.toString(); } diff --git a/remote-api/src/main/java/org/alfresco/rest/framework/webscripts/ResourceWebScriptHelper.java b/remote-api/src/main/java/org/alfresco/rest/framework/webscripts/ResourceWebScriptHelper.java index 1eb51e2c42..2ce6f7beeb 100644 --- a/remote-api/src/main/java/org/alfresco/rest/framework/webscripts/ResourceWebScriptHelper.java +++ b/remote-api/src/main/java/org/alfresco/rest/framework/webscripts/ResourceWebScriptHelper.java @@ -153,7 +153,7 @@ public class ResourceWebScriptHelper } } return CollectionWithPagingInfo.asPaged(collectionToWrap.getPaging(), resultCollection, collectionToWrap.hasMoreItems(), - collectionToWrap.getTotalItems(), sourceEntity, collectionToWrap.getContext()); + collectionToWrap.getTotalItems(), sourceEntity, collectionToWrap.getContext(), collectionToWrap.getNextSearchAfterToken()); } else { diff --git a/remote-api/src/main/resources/alfresco/public-rest-context.xml b/remote-api/src/main/resources/alfresco/public-rest-context.xml index b338a41701..4ec8159b7c 100644 --- a/remote-api/src/main/resources/alfresco/public-rest-context.xml +++ b/remote-api/src/main/resources/alfresco/public-rest-context.xml @@ -144,6 +144,7 @@ + @@ -1265,7 +1266,7 @@ - + diff --git a/remote-api/src/test/java/org/alfresco/rest/api/search/SearchMapperTests.java b/remote-api/src/test/java/org/alfresco/rest/api/search/SearchMapperTests.java index c37ea39d58..69dcd28d0a 100644 --- a/remote-api/src/test/java/org/alfresco/rest/api/search/SearchMapperTests.java +++ b/remote-api/src/test/java/org/alfresco/rest/api/search/SearchMapperTests.java @@ -194,6 +194,21 @@ public class SearchMapperTests assertEquals(searchParameters.getSkipCount(), paging.getSkipCount()); } + @Test + public void fromSearchAfter() throws Exception + { + SearchParameters searchParameters = new SearchParameters(); + searchMapper.fromPaging(searchParameters, Paging.valueOf(0, 100)); + assertNull(searchParameters.getSearchAfterToken()); + + searchMapper.fromPaging(searchParameters, Paging.valueOf(0, 100, "SEARCH_AFTER_TOKEN")); + assertEquals("SEARCH_AFTER_TOKEN", searchParameters.getSearchAfterToken()); + + // An explicit empty searchAfter starts a new cursor-paging session (first page). + searchMapper.fromPaging(searchParameters, Paging.valueOf(0, 100, "")); + assertEquals("", searchParameters.getSearchAfterToken()); + } + @Test public void fromSort() throws Exception { diff --git a/repository/src/main/java/org/alfresco/repo/search/SearchEngineResultSet.java b/repository/src/main/java/org/alfresco/repo/search/SearchEngineResultSet.java index 5a181e42f2..19e1ca8a4c 100644 --- a/repository/src/main/java/org/alfresco/repo/search/SearchEngineResultSet.java +++ b/repository/src/main/java/org/alfresco/repo/search/SearchEngineResultSet.java @@ -2,7 +2,7 @@ * #%L * Alfresco Data model classes * %% - * Copyright (C) 2005 - 2021 Alfresco Software Limited + * Copyright (C) 2005 - 2026 Alfresco Software Limited * %% * This file is part of the Alfresco software. * If the software was purchased under a paid Alfresco license, the terms of @@ -52,4 +52,9 @@ public interface SearchEngineResultSet extends ResultSet, SearchEngineResultMeta long getLastIndexedTxId(); boolean getProcessedDenies(); + + default String getNextSearchAfterToken() + { + return null; + } } diff --git a/repository/src/main/java/org/alfresco/repo/search/impl/elasticsearch/admin/SearchEngine.java b/repository/src/main/java/org/alfresco/repo/search/impl/elasticsearch/admin/SearchEngine.java new file mode 100644 index 0000000000..0aa07a2911 --- /dev/null +++ b/repository/src/main/java/org/alfresco/repo/search/impl/elasticsearch/admin/SearchEngine.java @@ -0,0 +1,31 @@ +/* + * #%L + * Alfresco Repository + * %% + * Copyright (C) 2005 - 2026 Alfresco Software Limited + * %% + * This file is part of the Alfresco software. + * If the software was purchased under a paid Alfresco license, the terms of + * the paid license agreement will prevail. Otherwise, the software is + * provided under the following open source license terms: + * + * Alfresco is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Alfresco is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Alfresco. If not, see . + * #L% + */ +package org.alfresco.repo.search.impl.elasticsearch.admin; + +public enum SearchEngine +{ + ELASTICSEARCH, OPENSEARCH, UNKNOWN +} diff --git a/repository/src/main/java/org/alfresco/repo/search/impl/elasticsearch/admin/SearchEngineDetector.java b/repository/src/main/java/org/alfresco/repo/search/impl/elasticsearch/admin/SearchEngineDetector.java new file mode 100644 index 0000000000..81b71bed5c --- /dev/null +++ b/repository/src/main/java/org/alfresco/repo/search/impl/elasticsearch/admin/SearchEngineDetector.java @@ -0,0 +1,126 @@ +/* + * #%L + * Alfresco Repository + * %% + * Copyright (C) 2005 - 2026 Alfresco Software Limited + * %% + * This file is part of the Alfresco software. + * If the software was purchased under a paid Alfresco license, the terms of + * the paid license agreement will prevail. Otherwise, the software is + * provided under the following open source license terms: + * + * Alfresco is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Alfresco is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Alfresco. If not, see . + * #L% + */ +package org.alfresco.repo.search.impl.elasticsearch.admin; + +import java.io.IOException; +import java.util.Locale; + +import org.json.JSONObject; +import org.opensearch.client.opensearch.generic.Body; +import org.opensearch.client.opensearch.generic.Request; +import org.opensearch.client.opensearch.generic.Requests; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.alfresco.repo.search.impl.elasticsearch.client.ElasticsearchHttpClientFactory; +import org.alfresco.repo.security.authentication.AuthenticationUtil; +import org.alfresco.repo.transaction.RetryingTransactionHelper; +import org.alfresco.service.cmr.attributes.AttributeService; +import org.alfresco.service.transaction.TransactionService; + +/** + * Detects the search engine provider (OpenSearch vs Elasticsearch) and version, and persists it via {@link AttributeService}. Invoked by {@link org.alfresco.repo.search.impl.elasticsearch.contentmodelsync.ElasticsearchInitialiser} once the engine is confirmed reachable. + */ +public class SearchEngineDetector +{ + private static final Logger LOGGER = LoggerFactory.getLogger(SearchEngineDetector.class); + + public static final String ATTR_ROOT = ".searchEngine"; + public static final String ATTR_SEARCH_ENGINE_NAME = "name"; // "OpenSearch" | "Elasticsearch" + public static final String ATTR_SEARCH_ENGINE_VERSION = "version"; // e.g. "2.13.0" + // Access the search engine info using something like: + // Search engine name: attributeService.getAttribute(ATTR_ROOT, ATTR_SEARCH_ENGINE_NAME) + // Search engine version: attributeService.getAttribute(ATTR_ROOT, ATTR_SEARCH_ENGINE_VERSION) + + private ElasticsearchHttpClientFactory httpClientFactory; + private AttributeService attributeService; + private TransactionService transactionService; + + public void detectAndStore() + { + try + { + SearchEngineInfo searchEngineInfo = detect();// [provider, version] + store(searchEngineInfo); + LOGGER.info("Detected search engine: {} {}", searchEngineInfo.getSearchEngineName(), searchEngineInfo.getSearchEngineVersion()); + } + catch (Exception e) + { + LOGGER.warn("Could not detect the search engine provider/version", e); + } + } + + private SearchEngineInfo detect() throws IOException + { + Request request = Requests.builder().method("GET").endpoint("/").build(); + try (var response = httpClientFactory.getElasticsearchClient().generic().execute(request)) + { + String raw = response.getBody() + .map(Body::bodyAsString) + .orElseThrow(() -> new IOException("Empty response from root endpoint")); + + JSONObject root = new JSONObject(raw); + String versionNumber = root.getJSONObject("version").getString("number"); + + SearchEngine searchEngine = switch (root.getString("tagline").toLowerCase(Locale.ROOT)) + { + case "you know, for search" -> SearchEngine.ELASTICSEARCH; + case "the opensearch project: https://opensearch.org/" -> SearchEngine.OPENSEARCH; + default -> SearchEngine.UNKNOWN; + }; + String provider = searchEngine.name().toLowerCase(Locale.ROOT); + return new SearchEngineInfo(provider, versionNumber); + } + } + + private void store(SearchEngineInfo searchEngineInfo) + { + AuthenticationUtil.runAs((AuthenticationUtil.RunAsWork) () -> { + RetryingTransactionHelper txnHelper = transactionService.getRetryingTransactionHelper(); + txnHelper.setForceWritable(true); + return txnHelper.doInTransaction(() -> { + attributeService.setAttribute(searchEngineInfo.getSearchEngineName(), ATTR_ROOT, ATTR_SEARCH_ENGINE_NAME); + attributeService.setAttribute(searchEngineInfo.getSearchEngineVersion(), ATTR_ROOT, ATTR_SEARCH_ENGINE_VERSION); + return null; + }, false, true); + }, AuthenticationUtil.getSystemUserName()); + } + + public void setHttpClientFactory(ElasticsearchHttpClientFactory httpClientFactory) + { + this.httpClientFactory = httpClientFactory; + } + + public void setAttributeService(AttributeService attributeService) + { + this.attributeService = attributeService; + } + + public void setTransactionService(TransactionService transactionService) + { + this.transactionService = transactionService; + } +} diff --git a/repository/src/main/java/org/alfresco/repo/search/impl/elasticsearch/admin/SearchEngineInfo.java b/repository/src/main/java/org/alfresco/repo/search/impl/elasticsearch/admin/SearchEngineInfo.java new file mode 100644 index 0000000000..8fc4b1a6d8 --- /dev/null +++ b/repository/src/main/java/org/alfresco/repo/search/impl/elasticsearch/admin/SearchEngineInfo.java @@ -0,0 +1,48 @@ +/* + * #%L + * Alfresco Repository + * %% + * Copyright (C) 2005 - 2026 Alfresco Software Limited + * %% + * This file is part of the Alfresco software. + * If the software was purchased under a paid Alfresco license, the terms of + * the paid license agreement will prevail. Otherwise, the software is + * provided under the following open source license terms: + * + * Alfresco is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Alfresco is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Alfresco. If not, see . + * #L% + */ +package org.alfresco.repo.search.impl.elasticsearch.admin; + +public class SearchEngineInfo +{ + private String searchEngineName; + private String searchEngineVersion; + + public SearchEngineInfo(String searchEngineName, String searchEngineVersion) + { + this.searchEngineName = searchEngineName; + this.searchEngineVersion = searchEngineVersion; + } + + public String getSearchEngineName() + { + return searchEngineName; + } + + public String getSearchEngineVersion() + { + return searchEngineVersion; + } +} diff --git a/repository/src/main/java/org/alfresco/repo/search/impl/elasticsearch/client/ElasticsearchHttpClientFactory.java b/repository/src/main/java/org/alfresco/repo/search/impl/elasticsearch/client/ElasticsearchHttpClientFactory.java index 66a25fe0c3..18fe5843a6 100644 --- a/repository/src/main/java/org/alfresco/repo/search/impl/elasticsearch/client/ElasticsearchHttpClientFactory.java +++ b/repository/src/main/java/org/alfresco/repo/search/impl/elasticsearch/client/ElasticsearchHttpClientFactory.java @@ -77,6 +77,9 @@ public class ElasticsearchHttpClientFactory private String baseUrl; private int port; + // Search engine implementation (elasticsearch, opensearch) + private String engine; + // SSL parameters for Elasticsearch server endpoint private String secureComms; private AlfrescoKeyStore sslTrustStore; @@ -235,6 +238,16 @@ public class ElasticsearchHttpClientFactory return (secureComms.equals("https") ? "https" : "http") + "://" + host + ":" + port + baseUrl; } + /** + * Gets the configured search engine implementation (elasticsearch, opensearch). + * + * @return the search engine implementation name + */ + public String getEngine() + { + return engine; + } + /** * Creates an Elasticsearch client applying parameters from properties file * @@ -460,6 +473,11 @@ public class ElasticsearchHttpClientFactory this.host = host; } + public void setEngine(String engine) + { + this.engine = engine; + } + public void setBaseUrl(String baseUrl) { this.baseUrl = baseUrl; diff --git a/repository/src/main/java/org/alfresco/repo/search/impl/elasticsearch/contentmodelsync/ElasticsearchInitialiser.java b/repository/src/main/java/org/alfresco/repo/search/impl/elasticsearch/contentmodelsync/ElasticsearchInitialiser.java index 16d159ac39..0ad4dd5b9d 100644 --- a/repository/src/main/java/org/alfresco/repo/search/impl/elasticsearch/contentmodelsync/ElasticsearchInitialiser.java +++ b/repository/src/main/java/org/alfresco/repo/search/impl/elasticsearch/contentmodelsync/ElasticsearchInitialiser.java @@ -46,6 +46,7 @@ import org.alfresco.repo.dictionary.DictionaryListener; import org.alfresco.repo.lock.JobLockService; import org.alfresco.repo.lock.JobLockService.JobLockRefreshCallback; import org.alfresco.repo.lock.LockAcquisitionException; +import org.alfresco.repo.search.impl.elasticsearch.admin.SearchEngineDetector; import org.alfresco.service.cmr.dictionary.PropertyDefinition; import org.alfresco.service.namespace.NamespaceService; import org.alfresco.service.namespace.QName; @@ -92,10 +93,11 @@ public class ElasticsearchInitialiser implements DictionaryListener private final Set modelCache = new HashSet<>(); // This counter will be used during the map model execution private final AtomicInteger globalModelInitialisedCounter = new AtomicInteger(0); + private SearchEngineDetector searchEngineDetector; public ElasticsearchInitialiser(DictionaryDAOImpl dictionary, ElasticsearchIndexService elasticsearchIndexService, ContentModelSynchronizer contentModelSynchronizer, JobLockService jobLockService, int retryAttempts, int retryPeriodSeconds, - int lockRetryAttempts, int lockRetryPeriodSeconds, boolean createIndexIfNotExists) + int lockRetryAttempts, int lockRetryPeriodSeconds, boolean createIndexIfNotExists, SearchEngineDetector searchEngineDetector) { this.dictionaryDAO = dictionary; this.dictionaryDAO.registerListener(this); @@ -107,6 +109,7 @@ public class ElasticsearchInitialiser implements DictionaryListener this.lockRetryPeriodSeconds = lockRetryPeriodSeconds; this.createIndexIfNotExists = createIndexIfNotExists; this.elasticsearchIndexService = elasticsearchIndexService; + this.searchEngineDetector = searchEngineDetector; } public ElasticsearchInitialiser() @@ -263,6 +266,11 @@ public class ElasticsearchInitialiser implements DictionaryListener } } LOGGER.info("Successfully connected to Elasticsearch index."); + + if (!isTerminated.get() && searchEngineDetector != null) + { + searchEngineDetector.detectAndStore(); + } // Attempt to map the models. mapModels(); } @@ -357,7 +365,7 @@ public class ElasticsearchInitialiser implements DictionaryListener /** * This method will be invoked at startup and every time a afterDictionaryInit event is triggered. - * + * * @return true if new models were mapped during the method execution, false otherwise. */ private boolean mapModels() diff --git a/repository/src/main/java/org/alfresco/repo/search/impl/elasticsearch/query/SearchRequestBuilderService.java b/repository/src/main/java/org/alfresco/repo/search/impl/elasticsearch/query/SearchRequestBuilderService.java index 020be8d8a7..252a42207a 100644 --- a/repository/src/main/java/org/alfresco/repo/search/impl/elasticsearch/query/SearchRequestBuilderService.java +++ b/repository/src/main/java/org/alfresco/repo/search/impl/elasticsearch/query/SearchRequestBuilderService.java @@ -95,19 +95,9 @@ public class SearchRequestBuilderService int size, String indexName) { - int trackTotalHitsLimit = DEFAULT_TRACK_TOTAL_HITS_UP_TO; - if (searchParameters.getTrackTotalHits() == -1 || searchParameters.getTrackTotalHits() >= TRACK_TOTAL_HITS_ACCURATE) - { - trackTotalHitsLimit = TRACK_TOTAL_HITS_ACCURATE; - } - else if (searchParameters.getTrackTotalHits() > 0) - { - trackTotalHitsLimit = searchParameters.getTrackTotalHits(); - } - SearchRequestWrapper.Builder wrapperBuilder = SearchRequestWrapper.builder(); SearchRequest.Builder builder = baseBuilder(queryWithPermissions) - .trackTotalHits(new TrackHits.Builder().count(trackTotalHitsLimit).build()) + .trackTotalHits(new TrackHits.Builder().count(resolveTrackTotalHitsLimit(searchParameters)).build()) .from(from) .size(size); @@ -148,6 +138,19 @@ public class SearchRequestBuilderService // Previous unified method with boolean flag removed. Update callers accordingly. + private int resolveTrackTotalHitsLimit(SearchParameters searchParameters) + { + if (searchParameters.getTrackTotalHits() == -1) + { + return TRACK_TOTAL_HITS_ACCURATE; + } + if (searchParameters.getTrackTotalHits() > 0) + { + return searchParameters.getTrackTotalHits(); + } + return DEFAULT_TRACK_TOTAL_HITS_UP_TO; + } + private SearchRequest.Builder baseBuilder(Query queryWithPermissions) { return new SearchRequest.Builder() diff --git a/repository/src/main/java/org/alfresco/repo/search/impl/elasticsearch/query/SearchStrategySelector.java b/repository/src/main/java/org/alfresco/repo/search/impl/elasticsearch/query/SearchStrategySelector.java index bc88d64272..3a195f41f7 100644 --- a/repository/src/main/java/org/alfresco/repo/search/impl/elasticsearch/query/SearchStrategySelector.java +++ b/repository/src/main/java/org/alfresco/repo/search/impl/elasticsearch/query/SearchStrategySelector.java @@ -39,7 +39,8 @@ public class SearchStrategySelector implements SearchStrategy private final SearchStrategy scrollStrategy; private final int maxResultWindow; - public SearchStrategySelector(SearchExecutionStrategy standardStrategy, SearchExecutionStrategy scrollStrategy, int maxResultWindow) + public SearchStrategySelector(SearchExecutionStrategy standardStrategy, SearchExecutionStrategy scrollStrategy, + int maxResultWindow) { this.standardStrategy = standardStrategy; this.scrollStrategy = scrollStrategy; diff --git a/repository/src/main/java/org/alfresco/repo/search/impl/elasticsearch/resultset/ElasticsearchResultSet.java b/repository/src/main/java/org/alfresco/repo/search/impl/elasticsearch/resultset/ElasticsearchResultSet.java index 196fa2cf06..7be0a362c2 100644 --- a/repository/src/main/java/org/alfresco/repo/search/impl/elasticsearch/resultset/ElasticsearchResultSet.java +++ b/repository/src/main/java/org/alfresco/repo/search/impl/elasticsearch/resultset/ElasticsearchResultSet.java @@ -65,11 +65,13 @@ public class ElasticsearchResultSet implements SearchEngineResultSet private final Map facetQueries; private final Map>> fieldFacets; private final Map>>> highlights; + private final String nextSearchAfterToken; + private final Boolean explicitHasMore; public ElasticsearchResultSet(NodeService nodeService, List nodeRefAndScores, SimpleResultSetMetaData resultSetMetaData, SpellCheckResult spellCheckResult, long queryTime, long numFound, int start, Map facetQueries, Map>> fieldFacets, - Map>>> highlights) + Map>>> highlights, String nextSearchAfterToken, Boolean explicitHasMore) { this.nodeService = nodeService; this.nodeRefAndScores = nodeRefAndScores; @@ -81,6 +83,8 @@ public class ElasticsearchResultSet implements SearchEngineResultSet this.facetQueries = facetQueries; this.fieldFacets = fieldFacets; this.highlights = highlights; + this.nextSearchAfterToken = nextSearchAfterToken; + this.explicitHasMore = explicitHasMore; } @Override @@ -154,9 +158,19 @@ public class ElasticsearchResultSet implements SearchEngineResultSet return start; } + @Override + public String getNextSearchAfterToken() + { + return nextSearchAfterToken; + } + @Override public boolean hasMore() { + if (explicitHasMore != null) + { + return explicitHasMore; + } return getNumberFound() > (getStart() + length()); } diff --git a/repository/src/main/java/org/alfresco/repo/search/impl/elasticsearch/resultset/ElasticsearchResultSetBuilder.java b/repository/src/main/java/org/alfresco/repo/search/impl/elasticsearch/resultset/ElasticsearchResultSetBuilder.java index 4d4b070208..b562ff570d 100644 --- a/repository/src/main/java/org/alfresco/repo/search/impl/elasticsearch/resultset/ElasticsearchResultSetBuilder.java +++ b/repository/src/main/java/org/alfresco/repo/search/impl/elasticsearch/resultset/ElasticsearchResultSetBuilder.java @@ -63,8 +63,24 @@ public class ElasticsearchResultSetBuilder this.aggregationHandler = aggregationHandler; } + public ElasticsearchResultSet build(SearchParameters searchParameters, SearchResponse searchResponse) + { + return build(searchParameters, searchResponse, null, false, Map.of(), Map.of()); + } + public ElasticsearchResultSet build(SearchParameters searchParameters, SearchResponse searchResponse, Map bucketsTranslator, Map> complementaryBucketsTranslator) + { + return build(searchParameters, searchResponse, null, false, bucketsTranslator, complementaryBucketsTranslator); + } + + public ElasticsearchResultSet build(SearchParameters searchParameters, SearchResponse searchResponse, String nextSearchAfterToken) + { + return build(searchParameters, searchResponse, nextSearchAfterToken, true, Map.of(), Map.of()); + } + + private ElasticsearchResultSet build(SearchParameters searchParameters, SearchResponse searchResponse, String nextSearchAfterToken, + boolean searchAfterMode, Map bucketsTranslator, Map> complementaryBucketsTranslator) { var hits = ofNullable(searchResponse.hits()).map(HitsMetadata::hits).orElse(List.of()); List nodeRefAndScores = mapNodeRefsAndScores(hits, searchParameters.isBulkFetchEnabled()); @@ -80,6 +96,11 @@ public class ElasticsearchResultSetBuilder Map facetQueries = aggregation.facetQueries(); Map>> fieldFacets = aggregation.fieldFacets(); Map>>> highlights = highlightsHandler.handle(searchParameters, searchResponse); + Boolean explicitHasMore = null; + if (searchAfterMode) + { + explicitHasMore = nextSearchAfterToken != null; + } return new ElasticsearchResultSet( nodeService, nodeRefAndScores, @@ -90,7 +111,9 @@ public class ElasticsearchResultSetBuilder start, facetQueries, fieldFacets, - highlights); + highlights, + nextSearchAfterToken, + explicitHasMore); } public ElasticsearchResultSet build(SearchParameters searchParameters, List> hits, long totalHits, long queryTime) @@ -112,7 +135,9 @@ public class ElasticsearchResultSetBuilder start, Map.of(), Map.of(), - Map.of()); + Map.of(), + null, + null); } private List mapNodeRefsAndScores(List> hits, boolean isBulkFetchEnabled) diff --git a/repository/src/main/resources/alfresco/subsystems/Search/elasticsearch/elasticsearch-community-context.xml b/repository/src/main/resources/alfresco/subsystems/Search/elasticsearch/elasticsearch-community-context.xml index cfa724f1fc..91150b4ab6 100644 --- a/repository/src/main/resources/alfresco/subsystems/Search/elasticsearch/elasticsearch-community-context.xml +++ b/repository/src/main/resources/alfresco/subsystems/Search/elasticsearch/elasticsearch-community-context.xml @@ -89,6 +89,14 @@ + + + + + + + . + * #L% + */ +package org.alfresco.repo.search.impl.elasticsearch.admin; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; +import static org.mockito.MockitoAnnotations.openMocks; + +import static org.alfresco.repo.search.impl.elasticsearch.admin.SearchEngineDetector.ATTR_ROOT; +import static org.alfresco.repo.search.impl.elasticsearch.admin.SearchEngineDetector.ATTR_SEARCH_ENGINE_NAME; +import static org.alfresco.repo.search.impl.elasticsearch.admin.SearchEngineDetector.ATTR_SEARCH_ENGINE_VERSION; + +import java.io.IOException; +import java.util.Optional; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.opensearch.client.opensearch.OpenSearchClient; +import org.opensearch.client.opensearch.generic.Body; +import org.opensearch.client.opensearch.generic.OpenSearchGenericClient; +import org.opensearch.client.opensearch.generic.Request; +import org.opensearch.client.opensearch.generic.Response; + +import org.alfresco.repo.search.impl.elasticsearch.client.ElasticsearchHttpClientFactory; +import org.alfresco.repo.security.authentication.AuthenticationUtil; +import org.alfresco.repo.security.authentication.AuthenticationUtil.RunAsWork; +import org.alfresco.repo.transaction.RetryingTransactionHelper; +import org.alfresco.repo.transaction.RetryingTransactionHelper.RetryingTransactionCallback; +import org.alfresco.service.cmr.attributes.AttributeService; +import org.alfresco.service.transaction.TransactionService; + +/** + * Unit tests for {@link SearchEngineDetector}. + *

+ * The search engine root endpoint ({@code GET /}) is mocked at the OpenSearch generic-client level so the provider/version detection and the {@link AttributeService} persistence can be verified without a running Elasticsearch/OpenSearch instance. + */ +public class SearchEngineDetectorTest +{ + private static final String SYSTEM_USER = "System"; + + private static final String ES_BODY = "{" + + "\"name\":\"node-1\",\"cluster_name\":\"docker-cluster\"," + + "\"version\":{\"number\":\"8.17.0\",\"build_flavor\":\"default\"}," + + "\"tagline\":\"You Know, for Search\"}"; + + private static final String OS_BODY = "{" + + "\"name\":\"node-1\",\"cluster_name\":\"docker-cluster\"," + + "\"version\":{\"distribution\":\"opensearch\",\"number\":\"2.17.0\"}," + + "\"tagline\":\"The OpenSearch Project: https://opensearch.org/\"}"; + + private static final String UNKNOWN_BODY = "{" + + "\"version\":{\"number\":\"1.2.3\"}," + + "\"tagline\":\"Some other search engine\"}"; + + @Mock + private ElasticsearchHttpClientFactory httpClientFactory; + @Mock + private AttributeService attributeService; + @Mock + private TransactionService transactionService; + @Mock + private RetryingTransactionHelper txnHelper; + @Mock + private OpenSearchClient openSearchClient; + @Mock + private OpenSearchGenericClient genericClient; + @Mock + private Response response; + @Mock + private Body body; + + private AutoCloseable mocks; + private MockedStatic authUtil; + + private SearchEngineDetector detector; + + @Before + public void setUp() + { + mocks = openMocks(this); + + // Run the "run as system" work and the transactional work inline so the persistence can be verified. + authUtil = mockStatic(AuthenticationUtil.class); + authUtil.when(AuthenticationUtil::getSystemUserName).thenReturn(SYSTEM_USER); + authUtil.when(() -> AuthenticationUtil.runAs(any(), any())) + .thenAnswer(call -> ((RunAsWork) call.getArgument(0)).doWork()); + + lenient().when(transactionService.getRetryingTransactionHelper()).thenReturn(txnHelper); + lenient().when(txnHelper.doInTransaction(any(), anyBoolean(), anyBoolean())) + .thenAnswer(call -> ((RetryingTransactionCallback) call.getArgument(0)).execute()); + + detector = new SearchEngineDetector(); + detector.setHttpClientFactory(httpClientFactory); + detector.setAttributeService(attributeService); + detector.setTransactionService(transactionService); + } + + @After + public void tearDown() throws Exception + { + authUtil.close(); + mocks.close(); + } + + @Test + public void shouldStoreElasticsearchProviderAndVersion() throws Exception + { + givenRootBody(ES_BODY); + + detector.detectAndStore(); + + verify(attributeService).setAttribute(eq("elasticsearch"), eq(ATTR_ROOT), eq(ATTR_SEARCH_ENGINE_NAME)); + verify(attributeService).setAttribute(eq("8.17.0"), eq(ATTR_ROOT), eq(ATTR_SEARCH_ENGINE_VERSION)); + } + + @Test + public void shouldStoreOpenSearchProviderAndVersion() throws Exception + { + givenRootBody(OS_BODY); + + detector.detectAndStore(); + + verify(attributeService).setAttribute(eq("opensearch"), eq(ATTR_ROOT), eq(ATTR_SEARCH_ENGINE_NAME)); + verify(attributeService).setAttribute(eq("2.17.0"), eq(ATTR_ROOT), eq(ATTR_SEARCH_ENGINE_VERSION)); + } + + @Test + public void shouldStoreUnknownWhenTaglineNotRecognised() throws Exception + { + givenRootBody(UNKNOWN_BODY); + + detector.detectAndStore(); + + verify(attributeService).setAttribute(eq("unknown"), eq(ATTR_ROOT), eq(ATTR_SEARCH_ENGINE_NAME)); + verify(attributeService).setAttribute(eq("1.2.3"), eq(ATTR_ROOT), eq(ATTR_SEARCH_ENGINE_VERSION)); + } + + @Test + public void shouldMatchTaglineCaseInsensitively() throws Exception + { + givenRootBody("{\"version\":{\"number\":\"8.1.0\"},\"tagline\":\"YOU KNOW, FOR SEARCH\"}"); + + detector.detectAndStore(); + + verify(attributeService).setAttribute(eq("elasticsearch"), eq(ATTR_ROOT), eq(ATTR_SEARCH_ENGINE_NAME)); + } + + @Test + public void shouldPersistAsSystemUserInWritableRequiresNewTransaction() throws Exception + { + givenRootBody(ES_BODY); + + detector.detectAndStore(); + + authUtil.verify(() -> AuthenticationUtil.runAs(any(RunAsWork.class), eq(SYSTEM_USER))); + verify(txnHelper).setForceWritable(true); + // readOnly = false, requiresNew = true + verify(txnHelper).doInTransaction(any(RetryingTransactionCallback.class), eq(false), eq(true)); + } + + @Test + public void shouldNotStoreWhenBodyIsEmpty() throws Exception + { + givenClientChain(); + when(response.getBody()).thenReturn(Optional.empty()); + + detector.detectAndStore(); + + verifyNoInteractions(attributeService); + } + + @Test + public void shouldNotStoreWhenBodyIsMalformedJson() throws Exception + { + givenRootBody("this is not json"); + + detector.detectAndStore(); + + verifyNoInteractions(attributeService); + } + + @Test + public void shouldNotStoreWhenVersionIsMissing() throws Exception + { + givenRootBody("{\"tagline\":\"You Know, for Search\"}"); + + detector.detectAndStore(); + + verifyNoInteractions(attributeService); + } + + @Test + public void shouldNotStoreWhenTaglineIsMissing() throws Exception + { + givenRootBody("{\"version\":{\"number\":\"8.17.0\"}}"); + + detector.detectAndStore(); + + verifyNoInteractions(attributeService); + } + + @Test + public void shouldNotStoreAndNotThrowWhenClientFails() throws Exception + { + givenClientChain(); + when(genericClient.execute(any(Request.class))).thenThrow(new IOException("engine unreachable")); + + detector.detectAndStore(); + + verifyNoInteractions(attributeService); + } + + private void givenClientChain() + { + when(httpClientFactory.getElasticsearchClient()).thenReturn(openSearchClient); + when(openSearchClient.generic()).thenReturn(genericClient); + } + + private void givenRootBody(String rawJson) throws IOException + { + givenClientChain(); + when(genericClient.execute(any(Request.class))).thenReturn(response); + when(response.getBody()).thenReturn(Optional.of(body)); + when(body.bodyAsString()).thenReturn(rawJson); + } +} diff --git a/repository/src/test/java/org/alfresco/repo/search/impl/elasticsearch/contentmodelsync/ElasticsearchInitialiserIT.java b/repository/src/test/java/org/alfresco/repo/search/impl/elasticsearch/contentmodelsync/ElasticsearchInitialiserIT.java index 21a01194c3..01a3840269 100644 --- a/repository/src/test/java/org/alfresco/repo/search/impl/elasticsearch/contentmodelsync/ElasticsearchInitialiserIT.java +++ b/repository/src/test/java/org/alfresco/repo/search/impl/elasticsearch/contentmodelsync/ElasticsearchInitialiserIT.java @@ -65,6 +65,7 @@ import org.opensearch.client.opensearch.indices.PutMappingRequest; import org.alfresco.repo.dictionary.DictionaryDAOImpl; import org.alfresco.repo.lock.JobLockService; import org.alfresco.repo.search.impl.elasticsearch.ElasticsearchSpringTest; +import org.alfresco.repo.search.impl.elasticsearch.admin.SearchEngineDetector; import org.alfresco.repo.search.impl.elasticsearch.client.ElasticsearchHttpClientFactory; import org.alfresco.repo.search.impl.elasticsearch.util.LogListAppender; @@ -102,7 +103,7 @@ public class ElasticsearchInitialiserIT extends ElasticsearchSpringTest fieldMappingBuilder, elasticsearchHttpClientFactory, Locale.ENGLISH.getLanguage(), indexConfigurationInitializer); toTest = new ElasticsearchInitialiser(dictionaryDAOImpl, elasticSearchIndexServiceSpy, contentModelSynchronizer, - jobLockService, 1, 1, 1, 1, true); + jobLockService, 1, 1, 1, 1, true, new SearchEngineDetector()); client = elasticsearchHttpClientFactory.getElasticsearchClient(); this.indicesSpy = spy(client.indices()); } diff --git a/repository/src/test/java/org/alfresco/repo/search/impl/elasticsearch/contentmodelsync/ElasticsearchInitialiserTest.java b/repository/src/test/java/org/alfresco/repo/search/impl/elasticsearch/contentmodelsync/ElasticsearchInitialiserTest.java index 330b75f2b2..96bf1b9cc0 100644 --- a/repository/src/test/java/org/alfresco/repo/search/impl/elasticsearch/contentmodelsync/ElasticsearchInitialiserTest.java +++ b/repository/src/test/java/org/alfresco/repo/search/impl/elasticsearch/contentmodelsync/ElasticsearchInitialiserTest.java @@ -62,6 +62,7 @@ import org.alfresco.repo.dictionary.CompiledModel; import org.alfresco.repo.dictionary.DictionaryDAOImpl; import org.alfresco.repo.lock.JobLockService; import org.alfresco.repo.lock.LockAcquisitionException; +import org.alfresco.repo.search.impl.elasticsearch.admin.SearchEngineDetector; import org.alfresco.service.namespace.QName; import org.alfresco.util.GUID; @@ -386,10 +387,10 @@ public class ElasticsearchInitialiserTest when(secondElasticsearchIndexService.createIndex()).thenReturn(true); ElasticsearchInitialiser elasticsearchInitialiser = new ElasticsearchInitialiser(mockDictionary, - mockElasticSearchIndexService, mockContentModelSynchronizer, jobLockService, 0, 0, 3, 0, true); + mockElasticSearchIndexService, mockContentModelSynchronizer, jobLockService, 0, 0, 3, 0, true, new SearchEngineDetector()); ElasticsearchInitialiser secondElasticsearchInitialiser = new ElasticsearchInitialiser(mockDictionary, - secondElasticsearchIndexService, secondContentModelSynchronizer, jobLockService, 0, 0, 3, 0, true); + secondElasticsearchIndexService, secondContentModelSynchronizer, jobLockService, 0, 0, 3, 0, true, new SearchEngineDetector()); Thread thread1 = new Thread(() -> elasticsearchInitialiser.initWithLock()); thread1.start(); diff --git a/repository/src/test/java/org/alfresco/repo/search/impl/elasticsearch/resultset/ElasticsearchResultSetBuilderTest.java b/repository/src/test/java/org/alfresco/repo/search/impl/elasticsearch/resultset/ElasticsearchResultSetBuilderTest.java index a30d0e3d75..5e30ff48df 100644 --- a/repository/src/test/java/org/alfresco/repo/search/impl/elasticsearch/resultset/ElasticsearchResultSetBuilderTest.java +++ b/repository/src/test/java/org/alfresco/repo/search/impl/elasticsearch/resultset/ElasticsearchResultSetBuilderTest.java @@ -281,6 +281,64 @@ public class ElasticsearchResultSetBuilderTest .anyMatch(pair -> "field1".equals(pair.getFirst()))); } + @Test + public void testBuild_searchAfterNotLastPage_hasMoreIsTrue() + { + // Given: a search_after page with a non-null nextSearchAfterToken (more pages remain), + // and skipCount is 0 as always the case for search_after cursor paging. + List> hits = createHitsList( + createHit(TEST_NODE_ID_1, TEST_SCORE_1), + createHit(TEST_NODE_ID_2, TEST_SCORE_2)); + setupSearchResponse(hits); + setupNodeExistence(true, true); + setupAggregationAndHighlights(); + + // When + ElasticsearchResultSet result = builder.build(searchParameters, searchResponse, "opaque-cursor-token"); + + // Then + assertTrue("hasMore should be true when a nextSearchAfterToken is present", result.hasMore()); + } + + @Test + public void testBuild_searchAfterLastPage_hasMoreIsFalse() + { + // Given: a search_after page with a null nextSearchAfterToken (last page). + // Without the fix, hasMore() would incorrectly return true here because + // getNumberFound() (TEST_TOTAL_HITS=3) > getStart()+length() (0+1) for search_after, + // since skipCount always stays 0 across search_after pages. + List> hits = createHitsList(createHit(TEST_NODE_ID_1, TEST_SCORE_1)); + setupSearchResponse(hits); + setupNodeExistence(true); + setupAggregationAndHighlights(); + + // When + ElasticsearchResultSet result = builder.build(searchParameters, searchResponse, null); + + // Then + assertTrue("hasMore should be false on the last search_after page", !result.hasMore()); + } + + @Test + public void testBuild_standardSearch_hasMoreUsesStartPlusLength() + { + // Given: a standard (non search_after) search where all TEST_TOTAL_HITS (3) results + // are returned in one page starting at skipCount 0, so there is nothing more to fetch. + List> hits = createHitsList( + createHit(TEST_NODE_ID_1, TEST_SCORE_1), + createHit(TEST_NODE_ID_2, TEST_SCORE_2), + createHit(TEST_NODE_ID_3, TEST_SCORE_3)); + setupSearchResponse(hits); + setupNodeExistence(true, true, true); + setupAggregationAndHighlights(); + + // When + ElasticsearchResultSet result = builder.build(searchParameters, searchResponse); + + // Then + assertTrue("hasMore should be false when all results were returned in this page", !result.hasMore()); + } + @Test public void testBuildWithHitsList_doesNotIncludeAggregationsOrHighlights() {