[ACS-12256] search_after pagination parameter addition (#4309)

This commit is contained in:
tathagta15
2026-08-24 14:03:51 +05:30
committed by GitHub
parent 47ec372ce0
commit c6aaae7005
30 changed files with 772 additions and 42 deletions
@@ -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();
}
+1 -1
View File
@@ -98,7 +98,7 @@
<dependency.awaitility.version>4.2.2</dependency.awaitility.version>
<!-- Elasticsearch / OpenSearch search support -->
<dependency.opensearch.version>2.21.0</dependency.opensearch.version>
<dependency.opensearch.version>2.26.0</dependency.opensearch.version>
<dependency.lucene.version>9.7.0</dependency.lucene.version>
<dependency.swagger-ui.version>4.1.3</dependency.swagger-ui.version>
<dependency.swagger-parser.version>1.0.73</dependency.swagger-parser.version>
@@ -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
@@ -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);
}
/**
@@ -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());
}
}
}
@@ -103,6 +103,10 @@ public class SerializerOfCollectionWithPaging extends StdSerializer<Serializable
jgen.writeNumberField(RecognizedParamsExtractor.PARAM_PAGING_SKIP, pagedCol.getPaging().getSkipCount());
jgen.writeNumberField(RecognizedParamsExtractor.PARAM_PAGING_MAX, pagedCol.getPaging().getMaxItems());
}
if (pagedCol.getNextSearchAfterToken() != null)
{
jgen.writeStringField("nextSearchAfterToken", pagedCol.getNextSearchAfterToken());
}
jgen.writeEndObject();
}
}
@@ -2,7 +2,7 @@
* #%L
* Alfresco Remote API
* %%
* Copyright (C) 2005 - 2016 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
@@ -70,4 +70,12 @@ public interface SerializablePagedCollection<T>
* 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;
}
}
@@ -50,6 +50,7 @@ public class CollectionWithPagingInfo<T> 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<T> implements SerializablePagedCollection<
* - The total number of items available.
*/
protected CollectionWithPagingInfo(Collection<T> collection, Paging paging, boolean hasMoreItems, Integer totalItems, Object sourceEntity, SearchContext context)
{
this(collection, paging, hasMoreItems, totalItems, sourceEntity, context, null);
}
protected CollectionWithPagingInfo(Collection<T> collection, Paging paging, boolean hasMoreItems, Integer totalItems, Object sourceEntity, SearchContext context, String nextSearchAfterToken)
{
super();
this.hasMoreItems = hasMoreItems;
@@ -81,6 +87,7 @@ public class CollectionWithPagingInfo<T> implements SerializablePagedCollection<
}
this.sourceEntity = sourceEntity;
this.context = context;
this.nextSearchAfterToken = nextSearchAfterToken;
}
/**
@@ -93,7 +100,7 @@ public class CollectionWithPagingInfo<T> implements SerializablePagedCollection<
public static <T> CollectionWithPagingInfo<T> from(SerializablePagedCollection<T> 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<T> 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<T> 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<T> implements SerializablePagedCollection<
return new CollectionWithPagingInfo<T>(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 <T> CollectionWithPagingInfo<T> asPaged(Paging paging, Collection<T> 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<T> implements SerializablePagedCollection<
return context;
}
@Override
public String getNextSearchAfterToken()
{
return nextSearchAfterToken;
}
}
@@ -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();
}
@@ -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
{
@@ -144,6 +144,7 @@
<entry key="java.lang.IllegalArgumentException" value="#{T(org.springframework.extensions.webscripts.Status).STATUS_BAD_REQUEST}" />
<entry key="org.alfresco.service.cmr.repository.CyclicChildRelationshipException" value="#{T(org.springframework.extensions.webscripts.Status).STATUS_BAD_REQUEST}" />
<entry key="org.alfresco.rest.framework.core.exceptions.InvalidArgumentException" value="#{T(org.springframework.extensions.webscripts.Status).STATUS_BAD_REQUEST}" />
<entry key="org.alfresco.repo.search.impl.elasticsearch.query.SearchStrategyException" value="#{T(org.springframework.extensions.webscripts.Status).STATUS_BAD_REQUEST}" />
<entry key="org.alfresco.service.cmr.version.VersionServiceException" value="#{T(org.springframework.extensions.webscripts.Status).STATUS_BAD_REQUEST}" />
<entry key="org.alfresco.service.cmr.repository.datatype.TypeConversionException" value="#{T(org.springframework.extensions.webscripts.Status).STATUS_BAD_REQUEST}" />
<entry key="org.alfresco.rest.framework.core.exceptions.NotFoundException" value="#{T(org.springframework.extensions.webscripts.Status).STATUS_NOT_FOUND}" />
@@ -1265,7 +1266,7 @@
<property name="resultMapper" ref="searchapiResultMapper" />
<property name="searchMapper" ref="searchapiSearchMapper" />
</bean>
<bean id="webscript.org.alfresco.api.SearchSQLApiWebscript.post"
class="org.alfresco.rest.api.search.SearchSQLApiWebscript" parent="webscript">
<property name="serviceRegistry" ref="ServiceRegistry" />
@@ -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
{
@@ -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;
}
}
@@ -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 <http://www.gnu.org/licenses/>.
* #L%
*/
package org.alfresco.repo.search.impl.elasticsearch.admin;
public enum SearchEngine
{
ELASTICSEARCH, OPENSEARCH, UNKNOWN
}
@@ -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 <http://www.gnu.org/licenses/>.
* #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<Void>) () -> {
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;
}
}
@@ -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 <http://www.gnu.org/licenses/>.
* #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;
}
}
@@ -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;
@@ -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<QName> 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()
@@ -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()
@@ -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;
@@ -65,11 +65,13 @@ public class ElasticsearchResultSet implements SearchEngineResultSet
private final Map<String, Integer> facetQueries;
private final Map<String, List<Pair<String, Integer>>> fieldFacets;
private final Map<NodeRef, List<Pair<String, List<String>>>> highlights;
private final String nextSearchAfterToken;
private final Boolean explicitHasMore;
public ElasticsearchResultSet(NodeService nodeService, List<NodeRefAndScore> nodeRefAndScores, SimpleResultSetMetaData resultSetMetaData,
SpellCheckResult spellCheckResult, long queryTime, long numFound, int start,
Map<String, Integer> facetQueries, Map<String, List<Pair<String, Integer>>> fieldFacets,
Map<NodeRef, List<Pair<String, List<String>>>> highlights)
Map<NodeRef, List<Pair<String, List<String>>>> 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());
}
@@ -63,8 +63,24 @@ public class ElasticsearchResultSetBuilder
this.aggregationHandler = aggregationHandler;
}
public ElasticsearchResultSet build(SearchParameters searchParameters, SearchResponse<Object> searchResponse)
{
return build(searchParameters, searchResponse, null, false, Map.of(), Map.of());
}
public ElasticsearchResultSet build(SearchParameters searchParameters, SearchResponse<Object> searchResponse, Map<String, String> bucketsTranslator,
Map<String, Pair<String, String>> complementaryBucketsTranslator)
{
return build(searchParameters, searchResponse, null, false, bucketsTranslator, complementaryBucketsTranslator);
}
public ElasticsearchResultSet build(SearchParameters searchParameters, SearchResponse<Object> searchResponse, String nextSearchAfterToken)
{
return build(searchParameters, searchResponse, nextSearchAfterToken, true, Map.of(), Map.of());
}
private ElasticsearchResultSet build(SearchParameters searchParameters, SearchResponse<Object> searchResponse, String nextSearchAfterToken,
boolean searchAfterMode, Map<String, String> bucketsTranslator, Map<String, Pair<String, String>> complementaryBucketsTranslator)
{
var hits = ofNullable(searchResponse.hits()).map(HitsMetadata::hits).orElse(List.of());
List<NodeRefAndScore> nodeRefAndScores = mapNodeRefsAndScores(hits, searchParameters.isBulkFetchEnabled());
@@ -80,6 +96,11 @@ public class ElasticsearchResultSetBuilder
Map<String, Integer> facetQueries = aggregation.facetQueries();
Map<String, List<Pair<String, Integer>>> fieldFacets = aggregation.fieldFacets();
Map<NodeRef, List<Pair<String, List<String>>>> 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<Hit<Object>> 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<NodeRefAndScore> mapNodeRefsAndScores(List<Hit<Object>> hits, boolean isBulkFetchEnabled)
@@ -89,6 +89,14 @@
<constructor-arg value="${elasticsearch.lockRetryAttempts}" />
<constructor-arg value="${elasticsearch.lockRetryPeriodSeconds}" />
<constructor-arg value="${elasticsearch.createIndexIfNotExists}" />
<constructor-arg ref="searchEngineDetector"/>
</bean>
<bean id="searchEngineDetector"
class="org.alfresco.repo.search.impl.elasticsearch.admin.SearchEngineDetector">
<property name="httpClientFactory" ref="elasticsearchHttpClientFactory"/>
<property name="attributeService" ref="AttributeService"/>
<property name="transactionService" ref="TransactionService"/>
</bean>
<bean id="elasticsearchIndexService"
@@ -77,6 +77,8 @@ elasticsearch.index.mapping.total_fields.limit=7500
elasticsearch.index.max_result_window=10000
elasticsearch.scroll.api_time=10s
elasticsearch.scroll.batch_size=100
# How long a search_after Point-In-Time is kept alive between deep-pagination pages. Refreshed on every page while the client is actively paging
elasticsearch.searchafter.keep_alive=1m
# Maximum numbers of facets that can be returned by a single query
elasticsearch.defaultFacetLimit=100
@@ -292,6 +292,7 @@ import org.alfresco.util.testing.category.NonBuildTests;
// Elasticsearch unit tests
org.alfresco.repo.search.impl.elasticsearch.admin.ElasticsearchDocumentsServiceTest.class,
org.alfresco.repo.search.impl.elasticsearch.admin.SearchEngineDetectorTest.class,
org.alfresco.repo.search.impl.elasticsearch.client.ElasticsearchHttpClientFactoryTest.class,
org.alfresco.repo.search.impl.elasticsearch.ElasticsearchSearchServiceTest.class,
org.alfresco.repo.search.impl.elasticsearch.ElasticsearchCategoryServiceTest.class,
@@ -37,6 +37,7 @@ import org.springframework.context.ApplicationContext;
import org.alfresco.repo.dictionary.DictionaryDAOImpl;
import org.alfresco.repo.lock.JobLockService;
import org.alfresco.repo.management.subsystems.SwitchableApplicationContextFactory;
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.contentmodelsync.ContentModelSynchronizer;
import org.alfresco.repo.search.impl.elasticsearch.contentmodelsync.ElasticsearchIndexService;
@@ -78,7 +79,7 @@ public abstract class ElasticsearchSpringTest extends BaseSpringTest
baseElasticsearchIndexService = new ElasticsearchIndexService(elasticsearchHttpClientFactory, 2000, 10000);
baseElasticsearchInitialiser = new ElasticsearchInitialiser(dictionaryDAOImpl, baseElasticsearchIndexService, contentModelSynchronizer,
jobLockService, 1, 1, 1, 1, true);
jobLockService, 1, 1, 1, 1, true, new SearchEngineDetector());
}
/** Get the name of the index. */
@@ -0,0 +1,258 @@
/*
* #%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 <http://www.gnu.org/licenses/>.
* #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}.
* <p>
* 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<AuthenticationUtil> 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);
}
}
@@ -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());
}
@@ -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();
@@ -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<Hit<Object>> 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<Hit<Object>> 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<Hit<Object>> 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()
{