mirror of
https://github.com/Alfresco/alfresco-community-repo.git
synced 2026-09-16 18:13:17 +00:00
[ACS-12256] search_after pagination parameter addition (#4309)
This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+4
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+9
-1
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+40
-3
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+31
-9
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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}" />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+31
@@ -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
|
||||
}
|
||||
+126
@@ -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;
|
||||
}
|
||||
}
|
||||
+48
@@ -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;
|
||||
}
|
||||
}
|
||||
+18
@@ -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;
|
||||
|
||||
+9
-1
@@ -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();
|
||||
}
|
||||
|
||||
+14
-11
@@ -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()
|
||||
|
||||
+2
-1
@@ -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;
|
||||
|
||||
+15
-1
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
+27
-2
@@ -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)
|
||||
|
||||
+8
@@ -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"
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
+2
-1
@@ -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. */
|
||||
|
||||
+258
@@ -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);
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -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());
|
||||
}
|
||||
|
||||
+3
-2
@@ -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();
|
||||
|
||||
+58
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user