Merge branch 'master' of github.com:Alfresco/alfresco-community-repo into fix/ACS-12618_handle-permissions-check-to-es

This commit is contained in:
Tiago Salvado
2026-08-26 17:07:25 +01:00
63 changed files with 1592 additions and 162 deletions
+1 -1
View File
@@ -7,7 +7,7 @@
<parent>
<groupId>org.alfresco</groupId>
<artifactId>alfresco-community-repo-amps</artifactId>
<version>26.3.0.32-SNAPSHOT</version>
<version>26.3.0.36-SNAPSHOT</version>
</parent>
<modules>
+1 -1
View File
@@ -7,7 +7,7 @@
<parent>
<groupId>org.alfresco</groupId>
<artifactId>alfresco-governance-services-community-parent</artifactId>
<version>26.3.0.32-SNAPSHOT</version>
<version>26.3.0.36-SNAPSHOT</version>
</parent>
<modules>
@@ -7,7 +7,7 @@
<parent>
<groupId>org.alfresco</groupId>
<artifactId>alfresco-governance-services-automation-community-repo</artifactId>
<version>26.3.0.32-SNAPSHOT</version>
<version>26.3.0.36-SNAPSHOT</version>
</parent>
<build>
+1 -1
View File
@@ -7,7 +7,7 @@
<parent>
<groupId>org.alfresco</groupId>
<artifactId>alfresco-governance-services-community-parent</artifactId>
<version>26.3.0.32-SNAPSHOT</version>
<version>26.3.0.36-SNAPSHOT</version>
</parent>
<modules>
@@ -8,7 +8,7 @@
<parent>
<groupId>org.alfresco</groupId>
<artifactId>alfresco-governance-services-community-repo-parent</artifactId>
<version>26.3.0.32-SNAPSHOT</version>
<version>26.3.0.36-SNAPSHOT</version>
</parent>
<properties>
@@ -7,7 +7,7 @@
<parent>
<groupId>org.alfresco</groupId>
<artifactId>alfresco-governance-services-community-repo-parent</artifactId>
<version>26.3.0.32-SNAPSHOT</version>
<version>26.3.0.36-SNAPSHOT</version>
</parent>
<build>
+1 -1
View File
@@ -7,7 +7,7 @@
<parent>
<groupId>org.alfresco</groupId>
<artifactId>alfresco-community-repo</artifactId>
<version>26.3.0.32-SNAPSHOT</version>
<version>26.3.0.36-SNAPSHOT</version>
</parent>
<modules>
+1 -1
View File
@@ -8,7 +8,7 @@
<parent>
<groupId>org.alfresco</groupId>
<artifactId>alfresco-community-repo-amps</artifactId>
<version>26.3.0.32-SNAPSHOT</version>
<version>26.3.0.36-SNAPSHOT</version>
</parent>
<properties>
@@ -233,15 +233,30 @@ var Filters =
filterParams.query = "+ID:\"" + parsedArgs.nodeRef + "\"";
break;
case "tag":
// Remove any trailing "/" character
if (filterData.charAt(filterData.length - 1) == "/")
{
filterData = filterData.slice(0, -1);
}
filterQuery = this.constructPathQuery(parsedArgs);
filterParams.query = filterQuery + " +TAG:\"" + search.ISO9075Encode(filterData) + "\"";
break;
case "tag":
// Remove any trailing "/" character
if (filterData.charAt(filterData.length - 1) == "/")
{
filterData = filterData.slice(0, -1);
}
filterParams.language = "fts-alfresco";
var encodedTag = search.ISO9075Encode(filterData.toLowerCase());
var tagNodes = search.luceneSearch('+PATH:"/cm:categoryRoot/cm:taggable//cm:' + encodedTag + '"');
if (tagNodes && tagNodes.length > 0)
{
filterParams.query = '+=cm\\:taggable:"' + tagNodes[0].nodeRef.toString() + '"';
}
else
{
// Unknown tag - return no results rather than every document
return {
query: null,
limitResults: 0,
sort: [],
language: "fts-alfresco"
};
}
break;
case "category":
@@ -41,6 +41,7 @@ import org.alfresco.util.testing.category.NonBuildTests;
org.alfresco.repo.wiki.WikiServiceImplTest.class,
org.alfresco.slingshot.documentlibrary.FolderTemplateTest.class,
org.alfresco.slingshot.web.scripts.SlingshotContentGetTest.class,
org.alfresco.slingshot.web.scripts.FiltersLibTest.class,
})
public class ShareServicesTestSuite
{}
@@ -0,0 +1,209 @@
/*
* Copyright 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/>.
*/
package org.alfresco.slingshot.web.scripts;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.mozilla.javascript.Context;
import org.mozilla.javascript.Scriptable;
import org.mozilla.javascript.ScriptableObject;
import org.mozilla.javascript.Undefined;
/**
* Unit tests for the "tag" filter query building logic in the slingshot doclist v2 web script library ({@code documentlibrary-v2/filters.lib.js}).
* <p>
* The test loads the <em>actual</em> {@code filters.lib.js} resource and executes {@code Filters.getFilterParams("tag", ...)} directly in the embedded Rhino JavaScript engine, injecting lightweight mock collaborators for the {@code args}, {@code search} and {@code logger} root objects. It therefore isolates and asserts the exact query string produced for a tag, with no database, Spring context or search index required.
* <p>
* a tag whose value contains a space (e.g. {@code "long tag"}) must be ISO9075-encoded when locating the tag node, and the resulting query must be a well-formed {@code +=cm:taggable:"<nodeRef>"} membership query rather than a broken multi-word query.
*
* @author GitHub Copilot
*/
public class FiltersLibTest
{
/** Classpath location of the library under test (packaged as a web script resource). */
private static final String FILTERS_LIB = "alfresco/templates/webscripts/org/alfresco/slingshot/documentlibrary-v2/filters.lib.js";
/** NodeRef that the mocked tag lookup ({@code search.luceneSearch}) resolves to. */
private static final String TAG_NODEREF = "workspace://SpacesStore/00000000-0000-0000-0000-000000000001";
/**
* Mock root objects and capture hooks, evaluated before the library so that the JavaScript globals referenced by {@code getFilterParams} (args, search, logger) are available. {@code __lastLuceneQuery} captures the path query passed to the tag lookup so the test can assert the tag value was correctly ISO9075-encoded.
*/
private static final String MOCKS = "var __lastLuceneQuery = null;\n"
+ "var __returnEmpty = false;\n"
+ "var search = {\n"
+ " ISO9075Encode: function(s) { return String(s).replace(/ /g, '_x0020_'); },\n"
+ " luceneSearch: function(q) {\n"
+ " __lastLuceneQuery = q;\n"
+ " return __returnEmpty ? [] : [ { nodeRef: { toString: function() { return '" + TAG_NODEREF + "'; } } } ];\n"
+ " }\n"
+ "};\n"
+ "var logger = {\n"
+ " isDebugLoggingEnabled: function() { return false; }, debug: function() {},\n"
+ " isWarnLoggingEnabled: function() { return false; }, warn: function() {},\n"
+ " isLoggingEnabled: function() { return false; }\n"
+ "};\n";
private Context cx;
private Scriptable scope;
@Before
public void setUp() throws IOException
{
cx = Context.enter();
// Interpretive mode - no bytecode generation required for a simple script evaluation
cx.setOptimizationLevel(-1);
scope = cx.initStandardObjects();
cx.evaluateString(scope, MOCKS, "mocks", 1, null);
cx.evaluateString(scope, readResource(FILTERS_LIB), "filters.lib.js", 1, null);
}
@After
public void tearDown()
{
Context.exit();
}
/**
* Baseline: a single-word tag resolves the tag node and produces a well-formed {@code +=cm:taggable:"<nodeRef>"} membership query using the fts-alfresco language.
*/
@Test
public void tagQueryForSingleWordTag()
{
Scriptable result = runTagFilter("mytag", false);
assertEquals("fts-alfresco", getString(result, "language"));
assertEquals("+=cm\\:taggable:\"" + TAG_NODEREF + "\"", getString(result, "query").trim());
// The tag value is looked up under the category root, ISO9075-encoded
assertEquals("+PATH:\"/cm:categoryRoot/cm:taggable//cm:mytag\"", getLastLuceneQuery());
}
/**
* when a tag containing a space must be ISO9075-encoded (space -> _x0020_) when the tag node is located, and must still yield a valid membership query.
*/
@Test
public void tagQueryForTagWithSpaceIsEncoded()
{
Scriptable result = runTagFilter("long tag", false);
assertEquals("fts-alfresco", getString(result, "language"));
assertEquals("+=cm\\:taggable:\"" + TAG_NODEREF + "\"", getString(result, "query").trim());
// The crux of the fix: the space is ISO9075-encoded rather than breaking the query
assertEquals("+PATH:\"/cm:categoryRoot/cm:taggable//cm:long_x0020_tag\"", getLastLuceneQuery());
}
/**
* The tag value is normalised before lookup: a trailing slash is stripped and the value is lower-cased, so mixed-case / trailing-slash input resolves to the same encoded path.
*/
@Test
public void tagValueIsNormalisedBeforeLookup()
{
runTagFilter("Long Tag/", false);
assertEquals("+PATH:\"/cm:categoryRoot/cm:taggable//cm:long_x0020_tag\"", getLastLuceneQuery());
}
/**
* Safety net: when the tag cannot be resolved to a node, the filter must return a null query (i.e. no results) rather than an unbounded query that would return every document.
*/
@Test
public void unknownTagReturnsNoResults()
{
Scriptable result = runTagFilter("does not exist", true);
assertNull("Unknown tag must produce a null query (no results)", getRaw(result, "query"));
assertEquals("fts-alfresco", getString(result, "language"));
assertEquals(0.0, Context.toNumber(getRaw(result, "limitResults")), 0.0);
}
// ---------------------------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------------------------
/**
* Invokes {@code Filters.getFilterParams("tag", parsedArgs, {})} with the given tag value and lookup behaviour, returning the resulting parameters object.
*
* @param filterData
* the raw tag value supplied as the {@code filterData} argument
* @param returnEmpty
* when {@code true}, the mocked tag lookup returns no matching node
* @return the JavaScript filter parameters object produced by the library
*/
private Scriptable runTagFilter(String filterData, boolean returnEmpty)
{
String setup = "args = { filterData: " + jsString(filterData) + ", sortAsc: null, sortField: null, max: null, days: null };\n"
+ "parsedArgs = { pathNode: { qnamePath: '/app:company_home' }, type: '' };\n"
+ "__returnEmpty = " + returnEmpty + ";\n"
+ "__result = Filters.getFilterParams('tag', parsedArgs, {});\n";
cx.evaluateString(scope, setup, "tag-filter-invocation", 1, null);
Object result = scope.get("__result", scope);
assertNotNull("getFilterParams should return an object", result);
assertTrue("getFilterParams should return a JavaScript object", result instanceof Scriptable);
return (Scriptable) result;
}
private String getLastLuceneQuery()
{
return Context.toString(scope.get("__lastLuceneQuery", scope));
}
private static Object getRaw(Scriptable obj, String property)
{
Object value = ScriptableObject.getProperty(obj, property);
if (value == Scriptable.NOT_FOUND || value instanceof Undefined)
{
return null;
}
return value;
}
private static String getString(Scriptable obj, String property)
{
Object value = getRaw(obj, property);
return value == null ? null : Context.toString(value);
}
/** Renders a Java string as a safe single-quoted JavaScript string literal. */
private static String jsString(String value)
{
return "'" + value.replace("\\", "\\\\").replace("'", "\\'") + "'";
}
private String readResource(String path) throws IOException
{
try (InputStream is = getClass().getClassLoader().getResourceAsStream(path))
{
assertNotNull("Could not find '" + path + "' on the test classpath", is);
return new String(is.readAllBytes(), StandardCharsets.UTF_8);
}
}
}
+1 -1
View File
@@ -7,7 +7,7 @@
<parent>
<groupId>org.alfresco</groupId>
<artifactId>alfresco-community-repo</artifactId>
<version>26.3.0.32-SNAPSHOT</version>
<version>26.3.0.36-SNAPSHOT</version>
</parent>
<dependencies>
+1 -1
View File
@@ -7,7 +7,7 @@
<parent>
<groupId>org.alfresco</groupId>
<artifactId>alfresco-community-repo</artifactId>
<version>26.3.0.32-SNAPSHOT</version>
<version>26.3.0.36-SNAPSHOT</version>
</parent>
<properties>
@@ -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
@@ -7,7 +7,7 @@
<parent>
<groupId>org.alfresco</groupId>
<artifactId>alfresco-community-repo</artifactId>
<version>26.3.0.32-SNAPSHOT</version>
<version>26.3.0.36-SNAPSHOT</version>
</parent>
<dependencies>
+1 -1
View File
@@ -9,6 +9,6 @@
<parent>
<groupId>org.alfresco</groupId>
<artifactId>alfresco-community-repo-packaging</artifactId>
<version>26.3.0.32-SNAPSHOT</version>
<version>26.3.0.36-SNAPSHOT</version>
</parent>
</project>
+1 -1
View File
@@ -7,7 +7,7 @@
<parent>
<groupId>org.alfresco</groupId>
<artifactId>alfresco-community-repo-packaging</artifactId>
<version>26.3.0.32-SNAPSHOT</version>
<version>26.3.0.36-SNAPSHOT</version>
</parent>
<properties>
+1 -1
View File
@@ -7,7 +7,7 @@
<parent>
<groupId>org.alfresco</groupId>
<artifactId>alfresco-community-repo</artifactId>
<version>26.3.0.32-SNAPSHOT</version>
<version>26.3.0.36-SNAPSHOT</version>
</parent>
<modules>
+1 -1
View File
@@ -6,7 +6,7 @@
<parent>
<groupId>org.alfresco</groupId>
<artifactId>alfresco-community-repo-packaging</artifactId>
<version>26.3.0.32-SNAPSHOT</version>
<version>26.3.0.36-SNAPSHOT</version>
</parent>
<modules>
+1 -1
View File
@@ -7,7 +7,7 @@
<parent>
<groupId>org.alfresco</groupId>
<artifactId>alfresco-community-repo-tests</artifactId>
<version>26.3.0.32-SNAPSHOT</version>
<version>26.3.0.36-SNAPSHOT</version>
</parent>
<organization>
+1 -1
View File
@@ -9,7 +9,7 @@
<parent>
<groupId>org.alfresco</groupId>
<artifactId>alfresco-community-repo-tests</artifactId>
<version>26.3.0.32-SNAPSHOT</version>
<version>26.3.0.36-SNAPSHOT</version>
</parent>
<developers>
+1 -1
View File
@@ -9,7 +9,7 @@
<parent>
<groupId>org.alfresco</groupId>
<artifactId>alfresco-community-repo-tests</artifactId>
<version>26.3.0.32-SNAPSHOT</version>
<version>26.3.0.36-SNAPSHOT</version>
</parent>
<developers>
+1 -1
View File
@@ -8,7 +8,7 @@
<parent>
<groupId>org.alfresco</groupId>
<artifactId>alfresco-community-repo-tests</artifactId>
<version>26.3.0.32-SNAPSHOT</version>
<version>26.3.0.36-SNAPSHOT</version>
</parent>
<properties>
@@ -0,0 +1,166 @@
/*
* #%L
* Alfresco Search Services E2E Test
* %%
* 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.rest.search;
import static org.testng.Assert.assertEquals;
import static org.testng.Assert.assertFalse;
import static org.testng.Assert.assertNotNull;
import static org.testng.Assert.assertTrue;
import java.util.List;
import io.restassured.path.json.JsonPath;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.testng.annotations.BeforeClass;
import org.testng.annotations.Test;
import org.alfresco.rest.core.RestRequest;
import org.alfresco.utility.Utility;
import org.alfresco.utility.data.RandomData;
import org.alfresco.utility.model.FileModel;
import org.alfresco.utility.model.FileType;
/**
* End-to-end test for the Share Document Library "tag" filter (slingshot {@code doclist} webscript, driven by {@code filters.lib.js}) running against a real search server.
* <p>
* clicking a tag that contains a space used to return either no documents or every document. This test tags documents through the public v1 REST API, waits for the live index to catch up, then calls the same {@code /slingshot/doclib2/doclist} endpoint the Share UI uses and asserts that the tag filter returns exactly the tagged document - for both a single-word tag and a tag containing a space.
* <p>
* The test lives in {@code org.alfresco.rest.search} so it is picked up automatically by the Elasticsearch E2E suite ({@code elasticsearch-e2e-suite.xml}), proving the fix works against an Elasticsearch server.
*/
@SuppressWarnings({"PMD.MethodNamingConventions", "PMD.LongVariable"})
public class DocumentLibraryTagFilterTest extends AbstractE2EFunctionalTest
{
/** Webscript service prefix for the slingshot doclist endpoint (equivalent to {@code /alfresco/s}). */
private static final String DOCLIST_BASE_PATH = "alfresco/service/slingshot/doclib2/doclist";
/** Default Share Document Library container name. */
private static final String DOCUMENT_LIBRARY = "documentLibrary";
private String singleWordTag;
private String spaceTag;
private FileModel singleWordTaggedFile;
private FileModel spaceTaggedFile;
@BeforeClass(alwaysRun = true)
public void dataPreparation()
{
// Unique suffix keeps the tags private to this test run (the tag filter is repo-wide, not site-scoped).
String unique = RandomData.getRandomName("Tag").toLowerCase();
singleWordTag = "single" + unique;
spaceTag = "long " + unique; // contains a space - the scenario that used to fail
singleWordTaggedFile = createTaggedFile(singleWordTag);
spaceTaggedFile = createTaggedFile(spaceTag);
// Wait until both tags resolve through the doclist endpoint (category node + cm:taggable both indexed).
assertTrue(waitForTagFilter(singleWordTag, singleWordTaggedFile.getName()),
"Single-word tag was not indexed/searchable in time: " + singleWordTag);
assertTrue(waitForTagFilter(spaceTag, spaceTaggedFile.getName()),
"Space-containing tag was not indexed/searchable in time: " + spaceTag);
}
/** A tag containing a space must return exactly the document it was applied to. */
@Test
public void tagFilterWithSpaceInTagNameReturnsOnlyTheTaggedDocument()
{
assertTagFilterReturnsExactly(spaceTag, spaceTaggedFile.getName(), singleWordTaggedFile.getName());
}
/** Regression guard: single-word tags keep working exactly as before. */
@Test
public void tagFilterWithSingleWordTagReturnsOnlyTheTaggedDocument()
{
assertTagFilterReturnsExactly(singleWordTag, singleWordTaggedFile.getName(), spaceTaggedFile.getName());
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
/** Creates a text document in the test site's document library and tags it via the public v1 REST API. */
private FileModel createTaggedFile(String tag)
{
FileModel file = FileModel.getRandomFileModel(FileType.TEXT_PLAIN, "MNT-25799 tag filter test content");
dataContent.usingUser(testUser).usingSite(testSite).createContent(file);
restClient.authenticateUser(testUser).withCoreAPI().usingResource(file).addTag(tag);
restClient.assertStatusCodeIs(HttpStatus.CREATED);
return file;
}
/** Runs the tag filter and asserts it returns exactly the expected file and never the other (unrelated) file. */
private void assertTagFilterReturnsExactly(String tag, String expectedFileName, String excludedFileName)
{
JsonPath json = tagFilter(tag);
restClient.assertStatusCodeIs(HttpStatus.OK);
List<String> fileNames = json.getList("items.location.file");
assertNotNull(fileNames, "Doclist response did not contain an items list for tag: " + tag);
assertTrue(fileNames.contains(expectedFileName),
"Tag filter '" + tag + "' did not return the tagged document '" + expectedFileName + "'. Got: " + fileNames);
assertFalse(fileNames.contains(excludedFileName),
"Tag filter '" + tag + "' incorrectly returned an unrelated document '" + excludedFileName + "'. Got: " + fileNames);
assertEquals(json.getInt("totalRecords"), 1,
"Tag filter '" + tag + "' returned an unexpected number of documents. Got: " + fileNames);
}
/** Polls the doclist tag filter until {@code expectedFileName} appears or the retry budget is exhausted. */
private boolean waitForTagFilter(String tag, String expectedFileName)
{
for (int attempt = 0; attempt < SEARCH_MAX_ATTEMPTS; attempt++)
{
JsonPath json = tagFilter(tag);
if (String.valueOf(HttpStatus.OK.value()).equals(restClient.getStatusCode()))
{
List<String> fileNames = json.getList("items.location.file");
if (fileNames != null && fileNames.contains(expectedFileName))
{
return true;
}
}
Utility.waitToLoopTime(properties.getSolrWaitTimeInSeconds(),
"Waiting for tag to be indexed. Attempt: " + (attempt + 1));
}
return false;
}
/**
* Calls the slingshot doclist webscript with the tag filter, as the Share UI does: {@code GET /alfresco/s/slingshot/doclib2/doclist/all/site/{site}/documentLibrary?filter=tag&filterData=<tag>}.
*/
private JsonPath tagFilter(String tag)
{
restClient.authenticateUser(testUser);
restClient.configureRequestSpec().setBasePath(DOCLIST_BASE_PATH);
RestRequest request = RestRequest.simpleRequest(HttpMethod.GET,
"all/site/{site}/{container}?filter=tag&filterData={filterData}",
testSite.getId(), DOCUMENT_LIBRARY, tag);
return restClient.process(request).getResponse().jsonPath();
}
}
+1 -1
View File
@@ -9,7 +9,7 @@
<parent>
<groupId>org.alfresco</groupId>
<artifactId>alfresco-community-repo-tests</artifactId>
<version>26.3.0.32-SNAPSHOT</version>
<version>26.3.0.36-SNAPSHOT</version>
</parent>
<developers>
+1 -1
View File
@@ -7,7 +7,7 @@
<parent>
<groupId>org.alfresco</groupId>
<artifactId>alfresco-community-repo-packaging</artifactId>
<version>26.3.0.32-SNAPSHOT</version>
<version>26.3.0.36-SNAPSHOT</version>
</parent>
<properties>
+4 -4
View File
@@ -2,7 +2,7 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<artifactId>alfresco-community-repo</artifactId>
<version>26.3.0.32-SNAPSHOT</version>
<version>26.3.0.36-SNAPSHOT</version>
<packaging>pom</packaging>
<name>Alfresco Community Repo Parent</name>
@@ -51,8 +51,8 @@
<dependency.alfresco-server-root.version>8.0.1</dependency.alfresco-server-root.version>
<dependency.activiti-engine.version>5.23.0</dependency.activiti-engine.version>
<dependency.activiti.version>5.23.0</dependency.activiti.version>
<dependency.alfresco-transform-core.version>5.4.3</dependency.alfresco-transform-core.version>
<dependency.alfresco-transform-service.version>4.4.3</dependency.alfresco-transform-service.version>
<dependency.alfresco-transform-core.version>5.4.4-A.6</dependency.alfresco-transform-core.version>
<dependency.alfresco-transform-service.version>4.4.4-A.7</dependency.alfresco-transform-service.version>
<dependency.alfresco-greenmail.version>7.1</dependency.alfresco-greenmail.version>
<dependency.acs-event-model.version>1.1.0-A.1</dependency.acs-event-model.version>
@@ -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>
+1 -1
View File
@@ -7,7 +7,7 @@
<parent>
<groupId>org.alfresco</groupId>
<artifactId>alfresco-community-repo</artifactId>
<version>26.3.0.32-SNAPSHOT</version>
<version>26.3.0.36-SNAPSHOT</version>
</parent>
<dependencies>
@@ -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
{
+1 -1
View File
@@ -7,7 +7,7 @@
<parent>
<groupId>org.alfresco</groupId>
<artifactId>alfresco-community-repo</artifactId>
<version>26.3.0.32-SNAPSHOT</version>
<version>26.3.0.36-SNAPSHOT</version>
</parent>
<dependencies>
@@ -26,16 +26,24 @@
package org.alfresco.repo.search;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.ListIterator;
import java.util.Map;
import org.alfresco.repo.search.impl.QueryParameterisationException;
import org.alfresco.service.cmr.repository.InvalidNodeRefException;
import org.alfresco.service.cmr.repository.NodeRef;
import org.alfresco.service.cmr.repository.StoreRef;
import org.alfresco.service.cmr.repository.XPathException;
import org.alfresco.service.cmr.repository.datatype.DefaultTypeConverter;
import org.alfresco.service.cmr.search.QueryParameter;
import org.alfresco.service.cmr.search.QueryParameterDefinition;
import org.alfresco.service.cmr.search.ResultSet;
import org.alfresco.service.cmr.search.SearchService;
import org.alfresco.service.namespace.NamespacePrefixResolver;
import org.alfresco.service.namespace.QName;
import org.alfresco.util.SearchLanguageConversion;
/**
@@ -73,4 +81,90 @@ public abstract class AbstractSearcherComponent implements SearchService
return selectProperties(contextNodeRef, xpath, parameters, namespacePrefixResolver, followAllParentLinks,
SearchService.LANGUAGE_XPATH);
}
@SuppressWarnings({"PMD.CognitiveComplexity", "PMD.CyclomaticComplexity", "PMD.NPathComplexity", "PMD.AssignmentInOperand"})
protected String parameterise(String unparameterised, Map<QName, QueryParameterDefinition> map, QueryParameter[] queryParameters, NamespacePrefixResolver nspr)
throws QueryParameterisationException
{
Map<QName, List<Serializable>> valueMap = new HashMap<>();
if (queryParameters != null)
{
for (QueryParameter parameter : queryParameters)
{
List<Serializable> list = valueMap.get(parameter.getQName());
if (list == null)
{
list = new ArrayList<>();
valueMap.put(parameter.getQName(), list);
}
list.add(parameter.getValue());
}
}
Map<QName, ListIterator<Serializable>> iteratorMap = new HashMap<>();
List<QName> missing = new ArrayList<>(1);
StringBuilder buffer = new StringBuilder(unparameterised);
int index = 0;
while ((index = buffer.indexOf("${", index)) != -1)
{
int endIndex = buffer.indexOf("}", index);
if (endIndex == -1)
{
throw new QueryParameterisationException("Unclosed query parameter placeholder starting at index " + index);
}
String qNameString = buffer.substring(index + 2, endIndex);
QName key = QName.createQName(qNameString, nspr);
QueryParameterDefinition parameterDefinition = map.get(key);
if (parameterDefinition == null)
{
missing.add(key);
buffer.replace(index, endIndex + 1, "");
}
else
{
ListIterator<Serializable> it = iteratorMap.get(key);
if ((it == null) || (!it.hasNext()))
{
List<Serializable> list = valueMap.get(key);
if ((list != null) && (!list.isEmpty()))
{
it = list.listIterator();
}
if (it != null)
{
iteratorMap.put(key, it);
}
}
String value;
if (it == null)
{
if (!parameterDefinition.hasDefaultValue())
{
throw new QueryParameterisationException("No value provided for query parameter: " + key);
}
value = parameterDefinition.getDefault();
}
else
{
value = DefaultTypeConverter.INSTANCE.convert(String.class, it.next());
}
buffer.replace(index, endIndex + 1, value);
}
}
if (!missing.isEmpty())
{
StringBuilder error = new StringBuilder();
error.append("The query uses the following parameters which are not defined: ");
for (QName qName : missing)
{
error.append(qName).append(", ");
}
error.delete(error.length() - 2, error.length());
throw new QueryParameterisationException(error.toString());
}
return buffer.toString();
}
}
@@ -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;
}
}
@@ -28,6 +28,7 @@ package org.alfresco.repo.search.impl.elasticsearch;
import static org.alfresco.repo.search.adaptor.QueryConstants.FIELD_TAG;
import java.io.Serializable;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
@@ -66,16 +67,19 @@ public class ElasticsearchSearchService extends AbstractSearcherComponent
private final Map<String, LuceneQueryLanguageSPI> queryLanguages;
private NodeService nodeService;
private DictionaryService dictionaryService;
private NamespacePrefixResolver namespacePrefixResolver;
public ElasticsearchSearchService(QueryRegisterComponent queryRegister,
Map<String, LuceneQueryLanguageSPI> queryLanguages,
NodeService nodeService,
DictionaryService dictionaryService)
DictionaryService dictionaryService,
NamespacePrefixResolver namespacePrefixResolver)
{
this.queryRegister = queryRegister;
this.queryLanguages = queryLanguages;
this.nodeService = nodeService;
this.dictionaryService = dictionaryService;
this.namespacePrefixResolver = namespacePrefixResolver;
}
@Override
@@ -115,6 +119,7 @@ public class ElasticsearchSearchService extends AbstractSearcherComponent
public ResultSet query(SearchParameters searchParameters)
{
adjustSearchParameters(searchParameters);
parameteriseQuery(searchParameters);
LuceneQueryLanguageSPI language = queryLanguages.get(searchParameters.getLanguage().toLowerCase());
if (language != null)
{
@@ -126,6 +131,25 @@ public class ElasticsearchSearchService extends AbstractSearcherComponent
}
}
private void parameteriseQuery(SearchParameters searchParameters)
{
if (searchParameters.getQuery() == null
|| searchParameters.getQueryParameterDefinitions() == null
|| searchParameters.getQueryParameterDefinitions().isEmpty())
{
return;
}
Map<QName, QueryParameterDefinition> map = new HashMap<>();
for (QueryParameterDefinition qpd : searchParameters.getQueryParameterDefinitions())
{
map.put(qpd.getQName(), qpd);
}
String parameterisedQueryString = parameterise(searchParameters.getQuery(), map, null, namespacePrefixResolver);
searchParameters.setQuery(parameterisedQueryString);
}
@Override
public List<NodeRef> selectNodes(NodeRef contextNodeRef, String xpath, QueryParameterDefinition[] parameters,
NamespacePrefixResolver namespacePrefixResolver, boolean followAllParentLinks)
@@ -35,6 +35,7 @@ import org.alfresco.service.cmr.dictionary.DictionaryService;
import org.alfresco.service.cmr.repository.NodeService;
import org.alfresco.service.cmr.repository.StoreRef;
import org.alfresco.service.cmr.search.SearchService;
import org.alfresco.service.namespace.NamespacePrefixResolver;
/**
* Factory for Indexer and Searcher instances for Elasticsearch
@@ -44,6 +45,7 @@ public class ElasticsearchSearchServiceFactory extends AbstractIndexerAndSearche
private QueryRegisterComponent queryRegister;
private NodeService nodeService;
private DictionaryService dictionaryService;
private NamespacePrefixResolver namespacePrefixResolver;
/**
* Elasticsearch is indexing the repository by polling database or listening queue messages from an external service, so no Indexer is available for the Repository.
@@ -57,7 +59,7 @@ public class ElasticsearchSearchServiceFactory extends AbstractIndexerAndSearche
@Override
public SearchService getSearcher(StoreRef storeRef, boolean searchDelta) throws SearcherException
{
return new ElasticsearchSearchService(queryRegister, getQueryLanguages(), nodeService, dictionaryService);
return new ElasticsearchSearchService(queryRegister, getQueryLanguages(), nodeService, dictionaryService, namespacePrefixResolver);
}
@Override
@@ -80,4 +82,9 @@ public class ElasticsearchSearchServiceFactory extends AbstractIndexerAndSearche
{
this.dictionaryService = dictionaryService;
}
public void setNamespacePrefixResolver(NamespacePrefixResolver namespacePrefixResolver)
{
this.namespacePrefixResolver = namespacePrefixResolver;
}
}
@@ -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)
@@ -30,10 +30,10 @@ import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.ListIterator;
import java.util.Map;
import java.util.Set;
import org.alfresco.repo.search.AbstractSearcherComponent;
import org.alfresco.repo.search.CannedQueryDef;
import org.alfresco.repo.search.QueryRegisterComponent;
import org.alfresco.repo.search.SearcherException;
@@ -61,7 +61,7 @@ import org.alfresco.util.SearchLanguageConversion;
/**
* @author Andy
*/
public class SolrSearchService implements SearchService
public class SolrSearchService extends AbstractSearcherComponent
{
private NodeService nodeService;
@@ -227,86 +227,6 @@ public class SolrSearchService implements SearchService
}
}
/* Parameterise the query string - not sure if it is required to escape lucence spacials chars The parameters could be used to build the query - the contents of parameters should alread have been escaped if required. ... mush better to provide the parameters and work out what to do TODO: conditional query escapement - may be we should have a parameter type that is not escaped */
private String parameterise(String unparameterised, Map<QName, QueryParameterDefinition> map, QueryParameter[] queryParameters, NamespacePrefixResolver nspr)
throws QueryParameterisationException
{
Map<QName, List<Serializable>> valueMap = new HashMap<QName, List<Serializable>>();
if (queryParameters != null)
{
for (QueryParameter parameter : queryParameters)
{
List<Serializable> list = valueMap.get(parameter.getQName());
if (list == null)
{
list = new ArrayList<Serializable>();
valueMap.put(parameter.getQName(), list);
}
list.add(parameter.getValue());
}
}
Map<QName, ListIterator<Serializable>> iteratorMap = new HashMap<QName, ListIterator<Serializable>>();
List<QName> missing = new ArrayList<QName>(1);
StringBuilder buffer = new StringBuilder(unparameterised);
int index = 0;
while ((index = buffer.indexOf("${", index)) != -1)
{
int endIndex = buffer.indexOf("}", index);
String qNameString = buffer.substring(index + 2, endIndex);
QName key = QName.createQName(qNameString, nspr);
QueryParameterDefinition parameterDefinition = map.get(key);
if (parameterDefinition == null)
{
missing.add(key);
buffer.replace(index, endIndex + 1, "");
}
else
{
ListIterator<Serializable> it = iteratorMap.get(key);
if ((it == null) || (!it.hasNext()))
{
List<Serializable> list = valueMap.get(key);
if ((list != null) && (list.size() > 0))
{
it = list.listIterator();
}
if (it != null)
{
iteratorMap.put(key, it);
}
}
String value;
if (it == null)
{
value = parameterDefinition.getDefault();
}
else
{
value = DefaultTypeConverter.INSTANCE.convert(String.class, it.next());
}
buffer.replace(index, endIndex + 1, value);
}
}
if (missing.size() > 0)
{
StringBuilder error = new StringBuilder();
error.append("The query uses the following parameters which are not defined: ");
for (QName qName : missing)
{
error.append(qName);
error.append(", ");
}
error.delete(error.length() - 1, error.length() - 1);
error.delete(error.length() - 1, error.length() - 1);
throw new QueryParameterisationException(error.toString());
}
return buffer.toString();
}
/* (non-Javadoc)
*
* @see org.alfresco.service.cmr.search.SearchService#query(org.alfresco.service.cmr.search.SearchParameters) */
@@ -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"
@@ -156,6 +164,7 @@
<property name="queryRegister" ref="search.queryRegisterComponent" />
<property name="nodeService" ref="nodeService" />
<property name="dictionaryService" ref="DictionaryService" />
<property name="namespacePrefixResolver" ref="namespaceService" />
</bean>
<bean id="elasticsearchQueryExecutor" abstract="true" class="org.alfresco.repo.search.impl.elasticsearch.query.ElasticsearchQueryExecutor">
@@ -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
@@ -243,6 +243,7 @@ import org.alfresco.util.testing.category.NonBuildTests;
org.alfresco.repo.domain.schema.script.ScriptBundleExecutorImplTest.class,
org.alfresco.repo.search.MLAnaysisModeExpansionTest.class,
org.alfresco.repo.search.DocumentNavigatorTest.class,
org.alfresco.repo.search.AbstractSearcherComponentTest.class,
org.alfresco.util.NumericEncodingTest.class,
org.alfresco.repo.search.impl.parsers.CMIS_FTSTest.class,
org.alfresco.repo.search.impl.parsers.CMISTest.class,
@@ -292,6 +293,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,
@@ -0,0 +1,223 @@
/*
* #%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;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.AssertionsForClassTypes.assertThatExceptionOfType;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.io.Serializable;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.mockito.junit.MockitoJUnitRunner;
import org.alfresco.repo.search.impl.QueryParameterisationException;
import org.alfresco.service.cmr.repository.InvalidNodeRefException;
import org.alfresco.service.cmr.repository.NodeRef;
import org.alfresco.service.cmr.repository.StoreRef;
import org.alfresco.service.cmr.repository.XPathException;
import org.alfresco.service.cmr.search.QueryParameter;
import org.alfresco.service.cmr.search.QueryParameterDefinition;
import org.alfresco.service.cmr.search.ResultSet;
import org.alfresco.service.cmr.search.SearchParameters;
import org.alfresco.service.namespace.NamespacePrefixResolver;
import org.alfresco.service.namespace.QName;
@RunWith(MockitoJUnitRunner.class)
public class AbstractSearcherComponentTest
{
private static final String TEST_URI = "http://www.alfresco.org/test/1.0";
private static final String PREFIX = "test";
private final AbstractSearcherComponent searcher = new StubSearcherComponent();
@Mock
private NamespacePrefixResolver namespacePrefixResolver;
@Before
public void setUp()
{
lenient().when(namespacePrefixResolver.getNamespaceURI(PREFIX)).thenReturn(TEST_URI);
}
private static QName paramQName(String localName)
{
return QName.createQName(TEST_URI, localName);
}
@Test
public void parameterise_substitutesSuppliedParameterValue()
{
Map<QName, QueryParameterDefinition> map = new HashMap<>();
map.put(paramQName("param"), mock(QueryParameterDefinition.class));
QueryParameter parameter = mock(QueryParameter.class);
when(parameter.getQName()).thenReturn(paramQName("param"));
when(parameter.getValue()).thenReturn("hello");
String result = searcher.parameterise("value:${test:param}", map, new QueryParameter[]{parameter}, namespacePrefixResolver);
assertThat(result).isEqualTo("value:hello");
}
@Test
public void parameterise_substitutesDefaultValueWhenNoParameterSupplied()
{
QueryParameterDefinition qpd = mock(QueryParameterDefinition.class);
when(qpd.hasDefaultValue()).thenReturn(true);
when(qpd.getDefault()).thenReturn("admin");
Map<QName, QueryParameterDefinition> map = new HashMap<>();
map.put(paramQName("param"), qpd);
String result = searcher.parameterise("user:${test:param}", map, null, namespacePrefixResolver);
assertThat(result).isEqualTo("user:admin");
}
@Test
public void parameterise_iteratesSuppliedValuesInOrderForRepeatedPlaceholder()
{
Map<QName, QueryParameterDefinition> map = new HashMap<>();
map.put(paramQName("param"), mock(QueryParameterDefinition.class));
QueryParameter first = mock(QueryParameter.class);
when(first.getQName()).thenReturn(paramQName("param"));
when(first.getValue()).thenReturn("one");
QueryParameter second = mock(QueryParameter.class);
when(second.getQName()).thenReturn(paramQName("param"));
when(second.getValue()).thenReturn("two");
String result = searcher.parameterise("${test:param}-${test:param}", map, new QueryParameter[]{first, second}, namespacePrefixResolver);
assertThat(result).isEqualTo("one-two");
}
@Test
public void parameterise_returnsQueryUnchangedWhenNoPlaceholders()
{
String result = searcher.parameterise("TYPE:\"cm:content\"", new HashMap<>(), null, namespacePrefixResolver);
assertThat(result).isEqualTo("TYPE:\"cm:content\"");
}
@Test
public void parameterise_throwsWhenPlaceholderReferencesUndefinedParameter()
{
assertThatExceptionOfType(QueryParameterisationException.class)
.isThrownBy(() -> searcher.parameterise("value:${test:param}", new HashMap<>(), null, namespacePrefixResolver))
.withMessageContaining("not defined");
}
@Test
public void parameterise_throwsWhenPlaceholderIsUnclosed()
{
assertThatExceptionOfType(QueryParameterisationException.class)
.isThrownBy(() -> searcher.parameterise("value:${test:param", new HashMap<>(), null, namespacePrefixResolver))
.withMessageContaining("Unclosed");
}
@Test
public void parameterise_throwsWhenParameterHasNoValueAndNoDefault()
{
QueryParameterDefinition qpd = mock(QueryParameterDefinition.class);
when(qpd.hasDefaultValue()).thenReturn(false);
Map<QName, QueryParameterDefinition> map = new HashMap<>();
map.put(paramQName("param"), qpd);
assertThatExceptionOfType(QueryParameterisationException.class)
.isThrownBy(() -> searcher.parameterise("value:${test:param}", map, null, namespacePrefixResolver))
.withMessageContaining("No value provided");
}
/**
* Minimal concrete subclass so the shared {@code protected} parameterise implementation can be exercised in isolation. The remaining {@link org.alfresco.service.cmr.search.SearchService} operations are not needed here.
*/
private static final class StubSearcherComponent extends AbstractSearcherComponent
{
@Override
public ResultSet query(StoreRef store, String language, String query, QueryParameterDefinition[] queryParameterDefinitions)
{
throw new UnsupportedOperationException();
}
@Override
public ResultSet query(StoreRef store, QName queryId, QueryParameter[] queryParameters)
{
throw new UnsupportedOperationException();
}
@Override
public ResultSet query(SearchParameters searchParameters)
{
throw new UnsupportedOperationException();
}
@Override
public List<NodeRef> selectNodes(NodeRef contextNodeRef, String xpath, QueryParameterDefinition[] parameters,
NamespacePrefixResolver namespacePrefixResolver, boolean followAllParentLinks, String language)
throws InvalidNodeRefException, XPathException
{
throw new UnsupportedOperationException();
}
@Override
public List<Serializable> selectProperties(NodeRef contextNodeRef, String xpath, QueryParameterDefinition[] parameters,
NamespacePrefixResolver namespacePrefixResolver, boolean followAllParentLinks, String language)
throws InvalidNodeRefException, XPathException
{
throw new UnsupportedOperationException();
}
@Override
public boolean contains(NodeRef nodeRef, QName propertyQName, String googleLikePattern) throws InvalidNodeRefException
{
throw new UnsupportedOperationException();
}
@Override
public boolean contains(NodeRef nodeRef, QName propertyQName, String googleLikePattern, SearchParameters.Operator defaultOperator)
throws InvalidNodeRefException
{
throw new UnsupportedOperationException();
}
@Override
public boolean like(NodeRef nodeRef, QName propertyQName, String sqlLikePattern, boolean includeFTS) throws InvalidNodeRefException
{
throw new UnsupportedOperationException();
}
}
}
@@ -33,6 +33,7 @@ import static org.mockito.Mockito.*;
import static org.alfresco.service.cmr.search.SearchService.LANGUAGE_FTS_ALFRESCO;
import static org.alfresco.service.cmr.search.SearchService.LANGUAGE_LUCENE;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
@@ -49,8 +50,11 @@ import org.alfresco.repo.search.SearcherException;
import org.alfresco.repo.search.impl.lucene.LuceneQueryLanguageSPI;
import org.alfresco.service.cmr.dictionary.DictionaryService;
import org.alfresco.service.cmr.repository.NodeService;
import org.alfresco.service.cmr.search.QueryParameterDefinition;
import org.alfresco.service.cmr.search.ResultSet;
import org.alfresco.service.cmr.search.SearchParameters;
import org.alfresco.service.namespace.NamespacePrefixResolver;
import org.alfresco.service.namespace.QName;
@RunWith(MockitoJUnitRunner.class)
public class ElasticsearchSearchServiceTest
@@ -71,6 +75,9 @@ public class ElasticsearchSearchServiceTest
@Mock
private DictionaryService dictionaryService;
@Mock
private NamespacePrefixResolver namespacePrefixResolver;
@Mock
private SearchParameters searchParameters;
@@ -83,9 +90,10 @@ public class ElasticsearchSearchServiceTest
Map<String, LuceneQueryLanguageSPI> languages = new HashMap<>();
languages.put(LANGUAGE.toLowerCase(Locale.getDefault()), queryLanguage);
searchService = new ElasticsearchSearchService(queryRegister, languages, nodeService, dictionaryService);
searchService = new ElasticsearchSearchService(queryRegister, languages, nodeService, dictionaryService, namespacePrefixResolver);
when(searchParameters.getLanguage()).thenReturn(LANGUAGE);
when(searchParameters.getQueryParameterDefinitions()).thenReturn(new ArrayList<>());
when(queryLanguage.executeQuery(any(SearchParameters.class))).thenReturn(resultSet);
}
@@ -152,6 +160,37 @@ public class ElasticsearchSearchServiceTest
verify(searchParameters, times(1)).setLanguage(LANGUAGE_FTS_ALFRESCO);
}
@Test
public void luceneQueryWithParameterDefinition_shouldSubstitutePlaceholderWithDefaultValue()
{
String qshareUri = "http://www.alfresco.org/model/qshare/1.0";
when(namespacePrefixResolver.getNamespaceURI("qshare")).thenReturn(qshareUri);
QName sharedBy = QName.createQName(qshareUri, "sharedBy");
QueryParameterDefinition qpd = mock(QueryParameterDefinition.class);
when(qpd.getQName()).thenReturn(sharedBy);
when(qpd.hasDefaultValue()).thenReturn(true);
when(qpd.getDefault()).thenReturn("admin");
when(searchParameters.getQuery()).thenReturn("ASPECT:\"qshare:shared\" +@qshare:sharedBy:\"${qshare:sharedBy}\"");
when(searchParameters.getQueryParameterDefinitions()).thenReturn(new ArrayList<>(List.of(qpd)));
searchService.query(searchParameters);
verify(searchParameters, times(1)).setQuery("ASPECT:\"qshare:shared\" +@qshare:sharedBy:\"admin\"");
}
@Test
public void luceneQueryWithoutParameterDefinitions_shouldNotSubstitute()
{
when(searchParameters.getQuery()).thenReturn("ASPECT:\"qshare:shared\"");
when(searchParameters.getQueryParameterDefinitions()).thenReturn(new ArrayList<>());
searchService.query(searchParameters);
verify(searchParameters, never()).setQuery(any());
}
@Test
public void luceneQuery_whenNotMatchingTagQuery_shouldNotBeModified()
{
@@ -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()
{