Transitional class — lives in the {@code elasticsearch.*} package alongside the other
+ * ES-specific implementations ({@link ESIndexAPI}, {@link ContentFactoryIndexOperationsES}).
+ * Will be deleted when the ES→OS migration completes.
+ *
+ *
Implements the same search logic as the legacy {@code ESSearchAPIImpl} but returns
+ * vendor-neutral {@link ContentSearchResponse} / {@link ContentSearchResults} DTOs instead
+ * of {@code SearchResponse} / {@code ESSearchResults}.
+ */
+public class ContentletSearchAPIES implements SearchAPI {
+
+ // -------------------------------------------------------------------------
+ // SearchAPI implementation
+ // -------------------------------------------------------------------------
+
+ @Override
+ public ContentSearchResults search(
+ final String query,
+ final boolean live,
+ final User user,
+ final boolean respectFrontendRoles)
+ throws DotSecurityException, DotDataException {
+
+ final String normalized = query != null
+ ? StringUtils.lowercaseStringExceptMatchingTokens(
+ query, ESContentFactoryImpl.LUCENE_RESERVED_KEYWORDS_REGEX)
+ : query;
+
+ final ContentSearchResponse resp = searchRaw(normalized, live, user, respectFrontendRoles);
+ final ContentSearchResults results = new ContentSearchResults(resp, new ArrayList<>());
+ results.setQuery(normalized);
+ results.setRewrittenQuery(normalized);
+
+ if (resp.hits() == null) {
+ return results;
+ }
+
+ final long start = System.currentTimeMillis();
+ final List list = new ArrayList<>();
+
+ for (final com.dotcms.content.index.domain.SearchHit sh : resp.hits()) {
+ try {
+ final Map sourceMap = sh.sourceAsMap();
+ final ContentletSearch conwrapper = new ContentletSearch();
+ conwrapper.setInode(sourceMap.get("inode").toString());
+ list.add(conwrapper);
+ } catch (final Exception e) {
+ Logger.error(this, e.getMessage(), e);
+ }
+ }
+
+ final List inodes = new ArrayList<>();
+ for (final ContentletSearch conwrap : list) {
+ inodes.add(conwrap.getInode());
+ }
+
+ final List contentlets = APILocator.getContentletAPIImpl().findContentlets(inodes);
+ for (final Contentlet contentlet : contentlets) {
+ if (contentlet.getInode() != null) {
+ results.add(contentlet);
+ }
+ }
+
+ results.setPopulationTook(System.currentTimeMillis() - start);
+ return results;
+ }
+
+ @Override
+ public ContentSearchResponse searchRaw(
+ final String query,
+ final boolean live,
+ final User user,
+ final boolean respectFrontendRoles)
+ throws DotSecurityException, DotDataException {
+
+ if (!UtilMethods.isSet(query)) {
+ throw new DotStateException("Search query is null");
+ }
+
+ final JSONObject completeQueryJSON;
+ try {
+ completeQueryJSON = new JSONObject(query);
+ completeQueryJSON.put("_source", new JSONArray("[identifier, inode]"));
+ } catch (final JSONException e) {
+ throw new DotStateException("Unable to parse the given query.", e);
+ }
+
+ final SearchResponse esResponse =
+ executeSearch(completeQueryJSON, live, user, respectFrontendRoles, -1, -1, null);
+ return ContentSearchResponse.from(esResponse);
+ }
+
+ @Override
+ public ContentSearchResponse searchRelated(
+ final String contentletIdentifier,
+ final String relationshipName,
+ final boolean pullParents,
+ final boolean live,
+ final User user,
+ final boolean respectFrontendRoles)
+ throws DotDataException, DotSecurityException {
+
+ final Contentlet contentlet =
+ APILocator.getContentletAPI().findContentletByIdentifierAnyLanguage(contentletIdentifier);
+ return searchRelated(contentlet, relationshipName, pullParents, live, user,
+ respectFrontendRoles, -1, -1, null);
+ }
+
+ @Override
+ public ContentSearchResponse searchRelated(
+ final Contentlet contentlet,
+ final String relationshipName,
+ final boolean pullParents,
+ final boolean live,
+ final User user,
+ final boolean respectFrontendRoles)
+ throws DotDataException, DotSecurityException {
+
+ return searchRelated(contentlet, relationshipName, pullParents, live, user,
+ respectFrontendRoles, -1, -1, null);
+ }
+
+ @Override
+ public ContentSearchResponse searchRelated(
+ final String contentletIdentifier,
+ final String relationshipName,
+ final boolean pullParents,
+ final boolean live,
+ final User user,
+ final boolean respectFrontendRoles,
+ final int limit,
+ final int offset,
+ final String sortBy)
+ throws DotDataException, DotSecurityException {
+
+ final Contentlet contentlet =
+ APILocator.getContentletAPI().findContentletByIdentifierAnyLanguage(contentletIdentifier);
+ return searchRelated(contentlet, relationshipName, pullParents, live, user,
+ respectFrontendRoles, limit, offset, sortBy);
+ }
+
+ @Override
+ public ContentSearchResponse searchRelated(
+ final Contentlet contentlet,
+ final String relationshipName,
+ final boolean pullParents,
+ final boolean live,
+ final User user,
+ final boolean respectFrontendRoles,
+ final int limit,
+ final int offset,
+ final String sortBy)
+ throws DotDataException, DotSecurityException {
+
+ final JSONObject completeQueryJSON = buildRelatedQuery(contentlet, relationshipName, pullParents);
+ final SearchResponse esResponse =
+ executeSearch(completeQueryJSON, false, user, respectFrontendRoles, limit, offset, sortBy);
+ return ContentSearchResponse.from(esResponse);
+ }
+
+ // -------------------------------------------------------------------------
+ // Private helpers
+ // -------------------------------------------------------------------------
+
+ private JSONObject buildRelatedQuery(
+ final Contentlet contentlet,
+ final String relationshipName,
+ final boolean pullParents) {
+ final JSONObject criteriaMap = new JSONObject();
+ try {
+ if (pullParents) {
+ criteriaMap.put("_source", "identifier");
+ criteriaMap.put("query", new JSONObject().put("match",
+ Map.of(relationshipName.toLowerCase(), contentlet.getIdentifier())));
+ } else {
+ criteriaMap.put("_source", relationshipName.toLowerCase());
+ criteriaMap.put("query",
+ new JSONObject().put("match", Map.of("inode", contentlet.getInode())));
+ }
+ } catch (final JSONException e) {
+ throw new DotStateException("Unable to build related query.", e);
+ }
+ return new JSONObject(criteriaMap.toString());
+ }
+
+ /**
+ * Executes a search against the active Elasticsearch index, applying permissions and sorting.
+ */
+ private SearchResponse executeSearch(
+ final JSONObject queryJson,
+ final boolean live,
+ final User user,
+ final boolean respectFrontendRoles,
+ final int limit,
+ final int offset,
+ final String sortBy)
+ throws DotSecurityException, DotDataException {
+
+ final String indexToHit;
+ try {
+ final IndiciesInfo info = APILocator.getIndiciesAPI().loadIndicies();
+ indexToHit = live ? info.getLive() : info.getWorking();
+ } catch (final DotDataException ee) {
+ Logger.fatal(this, "Can't get indices information", ee);
+ throw new DotDataException("Unable to load index information", ee);
+ }
+
+ if (user == null && !respectFrontendRoles) {
+ throw new DotSecurityException(
+ "You must specify a user if you are not respecting frontend roles");
+ }
+
+ List roles = new ArrayList<>();
+ boolean isAdmin = false;
+ if (user != null) {
+ if (!APILocator.getRoleAPI().doesUserHaveRole(user,
+ APILocator.getRoleAPI().loadCMSAdminRole())) {
+ roles = APILocator.getRoleAPI().loadRolesForUser(user.getUserId());
+ } else {
+ isAdmin = true;
+ }
+ }
+
+ final RestHighLevelClient client = RestHighLevelClientProvider.getInstance().getClient();
+ final SearchRequest request = new SearchRequest(indexToHit);
+
+ final StringBuffer perms = new StringBuffer();
+ if (!isAdmin && !queryJson.has("permissions:")) {
+ APILocator.getContentletAPIImpl()
+ .addPermissionsToQuery(perms, user, roles, respectFrontendRoles);
+ }
+
+ if (perms.length() > 0) {
+ try {
+ final JSONObject permissionsFilter = new JSONObject().put("query_string",
+ new JSONObject().put("query", perms.toString().trim()));
+ JSONArray boolFilters = new JSONArray("[" + permissionsFilter + "]");
+
+ if (queryJson.has("query")) {
+ final JSONObject currentQueryJSON =
+ new JSONObject(queryJson.getJSONObject("query").toString());
+ boolFilters = new JSONArray("[" + permissionsFilter + "," + currentQueryJSON + "]");
+ }
+
+ final JSONObject filteredJSON = new JSONObject().put("bool",
+ new JSONObject().put("must", new JSONObject().put("bool",
+ new JSONObject().put("must", boolFilters))));
+ queryJson.put("query", filteredJSON);
+ } catch (final JSONException e) {
+ throw new DotStateException("Unable to parse the given query.", e);
+ }
+ }
+
+ try {
+ final SearchSourceBuilder searchSourceBuilder =
+ SearchSourceBuilderUtil.getSearchSourceBuilder(queryJson.toString())
+ .timeout(TimeValue.timeValueMillis(INDEX_OPERATIONS_TIMEOUT_IN_MS));
+
+ if (limit > 0) {
+ searchSourceBuilder.size(limit);
+ }
+ if (offset > 0) {
+ searchSourceBuilder.from(offset);
+ }
+ if (UtilMethods.isSet(sortBy)) {
+ addBuilderSort(sortBy, searchSourceBuilder);
+ }
+
+ request.source(searchSourceBuilder);
+ return client.search(request, RequestOptions.DEFAULT);
+ } catch (final IOException e) {
+ throw new DotStateException(e.getMessage(), e);
+ }
+ }
+}
diff --git a/dotCMS/src/main/java/com/dotcms/content/elasticsearch/business/ESContentletAPIImpl.java b/dotCMS/src/main/java/com/dotcms/content/elasticsearch/business/ESContentletAPIImpl.java
index 14f321d47e39..23b2692b9f88 100644
--- a/dotCMS/src/main/java/com/dotcms/content/elasticsearch/business/ESContentletAPIImpl.java
+++ b/dotCMS/src/main/java/com/dotcms/content/elasticsearch/business/ESContentletAPIImpl.java
@@ -235,8 +235,8 @@
import org.apache.commons.io.FileUtils;
import org.apache.commons.lang.StringUtils;
import org.elasticsearch.action.search.SearchPhaseExecutionException;
+import com.dotcms.content.index.domain.ContentSearchResponse;
import org.elasticsearch.action.search.SearchResponse;
-import org.elasticsearch.search.SearchHit;
/**
@@ -2381,29 +2381,29 @@ private List getRelatedChildren(final Contentlet contentlet, final R
final String relationshipName = rel.getRelationTypeValue().toLowerCase();
final int limit = limitParam <= 0 ? MAX_LIMIT : limitParam;
- SearchResponse response;
+ ContentSearchResponse response;
final boolean DONT_PULL_PARENTS = Boolean.FALSE;
//Search for related content in existing contentlet
if (UtilMethods.isSet(contentlet.getInode())) {
- response = APILocator.getEsSearchAPI()
- .esSearchRelated(contentlet, relationshipName, DONT_PULL_PARENTS,
+ response = APILocator.getSearchAPI()
+ .searchRelated(contentlet, relationshipName, DONT_PULL_PARENTS,
WORKING_VERSION, user,
respectFrontendRoles, limit, offset, null);
} else {
//Search for related content in other versions of the same contentlet
- response = APILocator.getEsSearchAPI()
- .esSearchRelated(contentlet.getIdentifier(), relationshipName,
+ response = APILocator.getSearchAPI()
+ .searchRelated(contentlet.getIdentifier(), relationshipName,
DONT_PULL_PARENTS, WORKING_VERSION, user,
respectFrontendRoles, limit, offset, null);
}
- if (response.getHits() == null) {
+ if (response.hits().hits().isEmpty()) {
return result;
}
- for (final SearchHit sh : response.getHits()) {
- final Map sourceMap = sh.getSourceAsMap();
+ for (final com.dotcms.content.index.domain.SearchHit sh : response.hits()) {
+ final Map sourceMap = sh.sourceAsMap();
if (sourceMap.get(relationshipName) != null) {
List relatedIdentifiers = ((ArrayList) sourceMap.get(
relationshipName));
@@ -2475,25 +2475,25 @@ private List getRelatedParents(final Contentlet contentlet, final Re
final String relationshipName = rel.getRelationTypeValue().toLowerCase();
final int limit = limitParam <= 0 ? MAX_LIMIT : limitParam;
- SearchResponse response;
+ ContentSearchResponse response;
final boolean PULL_PARENTS = Boolean.TRUE;
//Search for related content in existing contentlet
if (UtilMethods.isSet(contentlet.getInode())) {
- response = APILocator.getEsSearchAPI()
- .esSearchRelated(contentlet, relationshipName, PULL_PARENTS,
+ response = APILocator.getSearchAPI()
+ .searchRelated(contentlet, relationshipName, PULL_PARENTS,
WORKING_VERSION, user,
respectFrontendRoles, limit, offset, null);
} else {
- response = APILocator.getEsSearchAPI()
- .esSearchRelated(contentlet.getIdentifier(), relationshipName, PULL_PARENTS,
+ response = APILocator.getSearchAPI()
+ .searchRelated(contentlet.getIdentifier(), relationshipName, PULL_PARENTS,
WORKING_VERSION, user,
respectFrontendRoles, limit, offset, null);
}
- if (response.getHits() != null) {
- for (final SearchHit sh : response.getHits()) {
- final Map sourceMap = sh.getSourceAsMap();
+ if (!response.hits().hits().isEmpty()) {
+ for (final com.dotcms.content.index.domain.SearchHit sh : response.hits()) {
+ final Map sourceMap = sh.sourceAsMap();
final String identifier = (String) sourceMap.get("identifier");
if (identifier != null && !relatedMap.containsKey(identifier)) {
final Contentlet mappedContentlet = findContentletByIdentifierAnyLanguage(
diff --git a/dotCMS/src/main/java/com/dotcms/content/index/SearchAPI.java b/dotCMS/src/main/java/com/dotcms/content/index/SearchAPI.java
new file mode 100644
index 000000000000..ef6d8c85764d
--- /dev/null
+++ b/dotCMS/src/main/java/com/dotcms/content/index/SearchAPI.java
@@ -0,0 +1,153 @@
+package com.dotcms.content.index;
+
+import com.dotcms.content.index.domain.ContentSearchResponse;
+import com.dotcms.content.index.domain.ContentSearchResults;
+import com.dotmarketing.exception.DotDataException;
+import com.dotmarketing.exception.DotSecurityException;
+import com.dotmarketing.portlets.contentlet.model.Contentlet;
+import com.liferay.portal.model.User;
+
+/**
+ * Vendor-neutral search API for executing full-text index queries.
+ *
+ *
This interface replaces the legacy {@code ESSeachAPI} whose method signatures
+ * leaked {@code org.elasticsearch.action.search.SearchResponse} and
+ * {@code ESSearchResults} into application code. Implementations delegate to the
+ * active search back-end (Elasticsearch or OpenSearch) as determined by the current
+ * migration phase.
+ *
+ *
All methods accept the same Lucene/JSON query format used by the existing search
+ * paths, so call sites only need to change the method names and return types.
+ *
+ * @see SearchAPIImpl Phase-aware router (register via {@code APILocator.getSearchAPI()})
+ */
+public interface SearchAPI {
+
+ /**
+ * Executes a JSON search query and returns the matching contentlets loaded from the DB.
+ *
+ *
Equivalent to {@code ESSeachAPI.esSearch()} but returns a vendor-neutral
+ * {@link ContentSearchResults} instead of {@code ESSearchResults}.
+ *
+ * @param query the JSON search query
+ * @param live {@code true} to query the live index
+ * @param user the user performing the action (may be {@code null} when
+ * {@code respectFrontendRoles} is {@code true})
+ * @param respectFrontendRoles whether front-end roles should be applied
+ * @return populated result list; never {@code null}
+ */
+ ContentSearchResults search(
+ String query,
+ boolean live,
+ User user,
+ boolean respectFrontendRoles)
+ throws DotSecurityException, DotDataException;
+
+ /**
+ * Executes a raw JSON search query and returns the index response without loading
+ * contentlets from the database.
+ *
+ *
Equivalent to {@code ESSeachAPI.esSearchRaw()} but returns a vendor-neutral
+ * {@link ContentSearchResponse} instead of {@code SearchResponse}.
+ *
+ * @param query the JSON search query
+ * @param live {@code true} to query the live index
+ * @param user the user performing the action
+ * @param respectFrontendRoles whether front-end roles should be applied
+ * @return raw search response; never {@code null}
+ */
+ ContentSearchResponse searchRaw(
+ String query,
+ boolean live,
+ User user,
+ boolean respectFrontendRoles)
+ throws DotSecurityException, DotDataException;
+
+ /**
+ * Returns related content for a given contentlet identifier (no pagination).
+ *
+ * @param contentletIdentifier identifier of the content whose relations will be searched
+ * @param relationshipName name of the relationship field
+ * @param pullParents {@code true} to search for parents, {@code false} for children
+ * @param live {@code true} to query the live index
+ * @param user the user performing the action
+ * @param respectFrontendRoles whether front-end roles should be applied
+ */
+ ContentSearchResponse searchRelated(
+ String contentletIdentifier,
+ String relationshipName,
+ boolean pullParents,
+ boolean live,
+ User user,
+ boolean respectFrontendRoles)
+ throws DotDataException, DotSecurityException;
+
+ /**
+ * Returns related content for a given contentlet (no pagination).
+ *
+ * @param contentlet the content whose relations will be searched
+ * @param relationshipName name of the relationship field
+ * @param pullParents {@code true} to search for parents, {@code false} for children
+ * @param live {@code true} to query the live index
+ * @param user the user performing the action
+ * @param respectFrontendRoles whether front-end roles should be applied
+ */
+ ContentSearchResponse searchRelated(
+ Contentlet contentlet,
+ String relationshipName,
+ boolean pullParents,
+ boolean live,
+ User user,
+ boolean respectFrontendRoles)
+ throws DotDataException, DotSecurityException;
+
+ /**
+ * Returns paginated related content for a given contentlet identifier.
+ *
+ * @param contentletIdentifier identifier of the content whose relations will be searched
+ * @param relationshipName name of the relationship field
+ * @param pullParents {@code true} to search for parents, {@code false} for children
+ * @param live {@code true} to query the live index
+ * @param user the user performing the action
+ * @param respectFrontendRoles whether front-end roles should be applied
+ * @param limit maximum number of results ({@code -1} for no limit)
+ * @param offset result offset for pagination
+ * @param sortBy sort expression, or {@code null} for default sort
+ */
+ ContentSearchResponse searchRelated(
+ String contentletIdentifier,
+ String relationshipName,
+ boolean pullParents,
+ boolean live,
+ User user,
+ boolean respectFrontendRoles,
+ int limit,
+ int offset,
+ String sortBy)
+ throws DotDataException, DotSecurityException;
+
+ /**
+ * Returns paginated related content for a given contentlet.
+ *
+ * @param contentlet the content whose relations will be searched
+ * @param relationshipName name of the relationship field
+ * @param pullParents {@code true} to search for parents, {@code false} for children
+ * @param live {@code true} to query the live index
+ * @param user the user performing the action
+ * @param respectFrontendRoles whether front-end roles should be applied
+ * @param limit maximum number of results ({@code -1} for no limit)
+ * @param offset result offset for pagination
+ * @param sortBy sort expression, or {@code null} for default sort
+ */
+ ContentSearchResponse searchRelated(
+ Contentlet contentlet,
+ String relationshipName,
+ boolean pullParents,
+ boolean live,
+ User user,
+ boolean respectFrontendRoles,
+ int limit,
+ int offset,
+ String sortBy)
+ throws DotDataException, DotSecurityException;
+}
diff --git a/dotCMS/src/main/java/com/dotcms/content/index/SearchAPIImpl.java b/dotCMS/src/main/java/com/dotcms/content/index/SearchAPIImpl.java
new file mode 100644
index 000000000000..fa153fbe2e54
--- /dev/null
+++ b/dotCMS/src/main/java/com/dotcms/content/index/SearchAPIImpl.java
@@ -0,0 +1,185 @@
+package com.dotcms.content.index;
+
+import com.dotcms.cdi.CDIUtils;
+import com.dotcms.content.elasticsearch.business.ContentletSearchAPIES;
+import com.dotcms.content.index.domain.ContentSearchResponse;
+import com.dotcms.content.index.domain.ContentSearchResults;
+import com.dotcms.content.index.opensearch.OSSearchAPIImpl;
+import com.dotcms.content.model.annotation.IndexLibraryIndependent;
+import com.dotmarketing.exception.DotDataException;
+import com.dotmarketing.exception.DotSecurityException;
+import com.dotmarketing.portlets.contentlet.model.Contentlet;
+import com.liferay.portal.model.User;
+
+/**
+ * Phase-aware router implementation of {@link SearchAPI}.
+ *
+ *
Routing table
+ *
+ * Phase | Read provider
+ * --------------------------|---------------
+ * 0 — not started | ES
+ * 1 — dual-write, ES reads | ES
+ * 2 — dual-write, OS reads | OS (with ES fallback on failure)
+ * 3 — OS only | OS
+ *
+ *
+ *
All search operations are reads; there are no write operations in this API, so the
+ * router only delegates to the read-path provider determined by {@link PhaseRouter#read}.
+ *
+ * @see PhaseRouter
+ * @see ContentletSearchAPIES
+ * @see OSSearchAPIImpl
+ */
+@IndexLibraryIndependent
+public class SearchAPIImpl implements SearchAPI {
+
+ private final ContentletSearchAPIES esImpl;
+ private final OSSearchAPIImpl osImpl;
+ private final PhaseRouter router;
+
+ public SearchAPIImpl() {
+ this(new ContentletSearchAPIES(), CDIUtils.getBeanThrows(OSSearchAPIImpl.class));
+ }
+
+ /** Package-private constructor for testing. */
+ SearchAPIImpl(final ContentletSearchAPIES esImpl, final OSSearchAPIImpl osImpl) {
+ this.esImpl = esImpl;
+ this.osImpl = osImpl;
+ this.router = new PhaseRouter<>(esImpl, osImpl);
+ }
+
+ /** Direct access to the ES implementation (for testing / bootstrap checks). */
+ public ContentletSearchAPIES esImpl() {
+ return esImpl;
+ }
+
+ /** Direct access to the OS implementation (for testing / bootstrap checks). */
+ public OSSearchAPIImpl osImpl() {
+ return osImpl;
+ }
+
+ // -------------------------------------------------------------------------
+ // SearchAPI — all operations are reads; delegate via router.readChecked
+ // -------------------------------------------------------------------------
+
+ @Override
+ public ContentSearchResults search(
+ final String query,
+ final boolean live,
+ final User user,
+ final boolean respectFrontendRoles)
+ throws DotSecurityException, DotDataException {
+ try {
+ return router.readChecked(impl ->
+ impl.search(query, live, user, respectFrontendRoles));
+ } catch (final DotSecurityException | DotDataException e) {
+ throw e;
+ } catch (final Exception e) {
+ throw new DotDataException(e.getMessage(), e);
+ }
+ }
+
+ @Override
+ public ContentSearchResponse searchRaw(
+ final String query,
+ final boolean live,
+ final User user,
+ final boolean respectFrontendRoles)
+ throws DotSecurityException, DotDataException {
+ try {
+ return router.readChecked(impl ->
+ impl.searchRaw(query, live, user, respectFrontendRoles));
+ } catch (final DotSecurityException | DotDataException e) {
+ throw e;
+ } catch (final Exception e) {
+ throw new DotDataException(e.getMessage(), e);
+ }
+ }
+
+ @Override
+ public ContentSearchResponse searchRelated(
+ final String contentletIdentifier,
+ final String relationshipName,
+ final boolean pullParents,
+ final boolean live,
+ final User user,
+ final boolean respectFrontendRoles)
+ throws DotDataException, DotSecurityException {
+ try {
+ return router.readChecked(impl -> impl.searchRelated(
+ contentletIdentifier, relationshipName, pullParents,
+ live, user, respectFrontendRoles));
+ } catch (final DotDataException | DotSecurityException e) {
+ throw e;
+ } catch (final Exception e) {
+ throw new DotDataException(e.getMessage(), e);
+ }
+ }
+
+ @Override
+ public ContentSearchResponse searchRelated(
+ final Contentlet contentlet,
+ final String relationshipName,
+ final boolean pullParents,
+ final boolean live,
+ final User user,
+ final boolean respectFrontendRoles)
+ throws DotDataException, DotSecurityException {
+ try {
+ return router.readChecked(impl -> impl.searchRelated(
+ contentlet, relationshipName, pullParents,
+ live, user, respectFrontendRoles));
+ } catch (final DotDataException | DotSecurityException e) {
+ throw e;
+ } catch (final Exception e) {
+ throw new DotDataException(e.getMessage(), e);
+ }
+ }
+
+ @Override
+ public ContentSearchResponse searchRelated(
+ final String contentletIdentifier,
+ final String relationshipName,
+ final boolean pullParents,
+ final boolean live,
+ final User user,
+ final boolean respectFrontendRoles,
+ final int limit,
+ final int offset,
+ final String sortBy)
+ throws DotDataException, DotSecurityException {
+ try {
+ return router.readChecked(impl -> impl.searchRelated(
+ contentletIdentifier, relationshipName, pullParents,
+ live, user, respectFrontendRoles, limit, offset, sortBy));
+ } catch (final DotDataException | DotSecurityException e) {
+ throw e;
+ } catch (final Exception e) {
+ throw new DotDataException(e.getMessage(), e);
+ }
+ }
+
+ @Override
+ public ContentSearchResponse searchRelated(
+ final Contentlet contentlet,
+ final String relationshipName,
+ final boolean pullParents,
+ final boolean live,
+ final User user,
+ final boolean respectFrontendRoles,
+ final int limit,
+ final int offset,
+ final String sortBy)
+ throws DotDataException, DotSecurityException {
+ try {
+ return router.readChecked(impl -> impl.searchRelated(
+ contentlet, relationshipName, pullParents,
+ live, user, respectFrontendRoles, limit, offset, sortBy));
+ } catch (final DotDataException | DotSecurityException e) {
+ throw e;
+ } catch (final Exception e) {
+ throw new DotDataException(e.getMessage(), e);
+ }
+ }
+}
diff --git a/dotCMS/src/main/java/com/dotcms/content/index/domain/AggregationBucket.java b/dotCMS/src/main/java/com/dotcms/content/index/domain/AggregationBucket.java
new file mode 100644
index 000000000000..cb7c21497fce
--- /dev/null
+++ b/dotCMS/src/main/java/com/dotcms/content/index/domain/AggregationBucket.java
@@ -0,0 +1,70 @@
+package com.dotcms.content.index.domain;
+
+import java.util.List;
+import java.util.stream.Collectors;
+import org.immutables.value.Value;
+
+/**
+ * Vendor-neutral representation of a single bucket in a terms aggregation.
+ *
+ *
Replaces direct use of {@code org.elasticsearch.search.aggregations.bucket.terms.Terms.Bucket}
+ * and {@code org.opensearch.client.opensearch._types.aggregations.StringTermsBucket} in
+ * application code.
+ */
+@Value.Immutable
+public interface AggregationBucket {
+
+ /** Bucket key as a String (numeric keys are converted via {@code toString()}). */
+ String key();
+
+ /** Number of documents in this bucket. */
+ long docCount();
+
+ static ImmutableAggregationBucket.Builder builder() {
+ return ImmutableAggregationBucket.builder();
+ }
+
+ // -------------------------------------------------------------------------
+ // ES factories
+ // -------------------------------------------------------------------------
+
+ /** Creates a bucket from an Elasticsearch terms bucket. */
+ static AggregationBucket from(
+ final org.elasticsearch.search.aggregations.bucket.terms.Terms.Bucket esBucket) {
+ return builder()
+ .key(esBucket.getKeyAsString())
+ .docCount(esBucket.getDocCount())
+ .build();
+ }
+
+ // -------------------------------------------------------------------------
+ // OS factories
+ // -------------------------------------------------------------------------
+
+ /** Creates a bucket from an OpenSearch string-terms bucket. */
+ static AggregationBucket fromOS(
+ final org.opensearch.client.opensearch._types.aggregations.StringTermsBucket osBucket) {
+ return builder()
+ .key(osBucket.key())
+ .docCount(osBucket.docCount())
+ .build();
+ }
+
+ /** Creates a bucket from an OpenSearch long-terms bucket. */
+ static AggregationBucket fromOS(
+ final org.opensearch.client.opensearch._types.aggregations.LongTermsBucket osBucket) {
+ return builder()
+ .key(String.valueOf(osBucket.key()))
+ .docCount(osBucket.docCount())
+ .build();
+ }
+
+ /** Creates a bucket from an OpenSearch double-terms bucket. */
+ static AggregationBucket fromOS(
+ final org.opensearch.client.opensearch._types.aggregations.DoubleTermsBucket osBucket) {
+ return builder()
+ .key(String.valueOf(osBucket.key()))
+ .docCount(osBucket.docCount())
+ .build();
+ }
+}
diff --git a/dotCMS/src/main/java/com/dotcms/content/index/domain/ContentSearchResponse.java b/dotCMS/src/main/java/com/dotcms/content/index/domain/ContentSearchResponse.java
new file mode 100644
index 000000000000..a239f1cfe4c4
--- /dev/null
+++ b/dotCMS/src/main/java/com/dotcms/content/index/domain/ContentSearchResponse.java
@@ -0,0 +1,117 @@
+package com.dotcms.content.index.domain;
+
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+import javax.annotation.Nullable;
+import org.immutables.value.Value;
+
+/**
+ * Vendor-neutral representation of a raw search response.
+ *
+ *
Replaces direct use of {@code org.elasticsearch.action.search.SearchResponse} in
+ * application code. Carries the search hits, timing metadata, scroll ID, and
+ * the first-level terms aggregations (the only aggregation type used in dotCMS).
+ *
+ *
Factory methods ({@code from(ES)}, {@code from(OS)}) map vendor types to this
+ * neutral DTO; they are the only places where vendor imports are allowed in this file.
+ */
+@Value.Immutable
+public interface ContentSearchResponse {
+
+ /** Neutral search hits (already vendor-independent). */
+ SearchHits hits();
+
+ /**
+ * Scroll ID returned by the cluster, or {@code null} when not a scroll request.
+ * ES: {@code SearchResponse.getScrollId()} / OS: {@code SearchResponse.scrollId()}
+ */
+ @Nullable
+ String scrollId();
+
+ /** Time the cluster took to execute the query, in milliseconds. */
+ long tookMillis();
+
+ /**
+ * First-level terms aggregations, keyed by aggregation name.
+ * Only {@code terms} aggregations are mapped; other types are silently skipped.
+ */
+ @Value.Default
+ default Map> aggregations() {
+ return Collections.emptyMap();
+ }
+
+ static ImmutableContentSearchResponse.Builder builder() {
+ return ImmutableContentSearchResponse.builder();
+ }
+
+ // -------------------------------------------------------------------------
+ // ES factory
+ // -------------------------------------------------------------------------
+
+ static ContentSearchResponse from(
+ final org.elasticsearch.action.search.SearchResponse esResponse) {
+
+ final Map> aggs = new LinkedHashMap<>();
+ if (esResponse.getAggregations() != null) {
+ for (final org.elasticsearch.search.aggregations.Aggregation agg
+ : esResponse.getAggregations().asList()) {
+ if (agg instanceof org.elasticsearch.search.aggregations.bucket.terms.Terms) {
+ final org.elasticsearch.search.aggregations.bucket.terms.Terms termAgg =
+ (org.elasticsearch.search.aggregations.bucket.terms.Terms) agg;
+ aggs.put(agg.getName(), termAgg.getBuckets().stream()
+ .map(AggregationBucket::from)
+ .collect(Collectors.toList()));
+ }
+ }
+ }
+
+ return builder()
+ .hits(SearchHits.from(esResponse.getHits()))
+ .scrollId(esResponse.getScrollId())
+ .tookMillis(esResponse.getTook() != null ? esResponse.getTook().getMillis() : 0L)
+ .aggregations(aggs)
+ .build();
+ }
+
+ // -------------------------------------------------------------------------
+ // OS factory
+ // -------------------------------------------------------------------------
+
+ static ContentSearchResponse from(
+ final org.opensearch.client.opensearch.core.SearchResponse
+ *
+ * @author Fabrizzio Araya
+ */
+@ApplicationScoped
+@RunWith(DataProviderWeldRunner.class)
+public class OSSearchAPIImplIntegrationTest extends IntegrationTestBase {
+
+ private static final String RUN_ID =
+ UUID.randomUUID().toString().replace("-", "").substring(0, 8);
+
+ private static final String IDX_LIVE = "live_search_" + RUN_ID;
+ private static final String IDX_WORKING = "working_search_" + RUN_ID;
+
+ /**
+ * The version constant used by {@link VersionedIndicesAPI#loadDefaultVersionedIndices()}.
+ * Test indices must be registered under this version for {@code OSSearchAPIImpl.resolveIndex()}
+ * to find them.
+ */
+ private static final String DEFAULT_OS_VERSION = VersionedIndices.OPENSEARCH_3X;
+
+ // ── CDI-injected beans ──────────────────────────────────────────────────
+ @Inject
+ private OSSearchAPIImpl osSearchAPI;
+
+ @Inject
+ private OSIndexAPIImpl osIndexAPI;
+
+ @Inject
+ private VersionedIndicesAPI versionedIndicesAPI;
+
+ private User systemUser;
+
+ // =======================================================================
+ // Lifecycle
+ // =======================================================================
+
+ @BeforeClass
+ public static void prepare() throws Exception {
+ IntegrationTestInitService.getInstance().init();
+ }
+
+ @Before
+ public void setUp() throws Exception {
+ cleanupTestOsIndices();
+ cleanupVersionedRows();
+
+ systemUser = APILocator.getUserAPI().getSystemUser();
+
+ // Create OS indices and register them under the default OPENSEARCH_3X version
+ // so resolveIndex() (which calls loadDefaultVersionedIndices()) finds them.
+ osIndexAPI.createIndex(IDX_LIVE, 1);
+ osIndexAPI.createIndex(IDX_WORKING, 1);
+
+ versionedIndicesAPI.saveIndices(
+ VersionedIndicesImpl.builder()
+ .version(DEFAULT_OS_VERSION)
+ .live(IDX_LIVE)
+ .working(IDX_WORKING)
+ .build());
+
+ // Ensure resolveIndex() reads the freshly saved rows, not a cached prior state.
+ versionedIndicesAPI.clearCache();
+ }
+
+ @After
+ public void tearDown() {
+ cleanupTestOsIndices();
+ cleanupVersionedRows();
+ }
+
+ // =======================================================================
+ // Tests – searchRaw
+ // =======================================================================
+
+ /**
+ * Given scenario: A valid match-all JSON query against an empty working index.
+ * Expected: {@link OSSearchAPIImpl#searchRaw} returns a non-null {@link ContentSearchResponse}
+ * with a non-null {@link com.dotcms.content.index.domain.SearchHits} and zero total hits.
+ */
+ @Test
+ public void test_searchRaw_matchAll_shouldReturnEmptyResultsWithNonNullStructure()
+ throws Exception {
+
+ final String matchAll = "{\"query\":{\"match_all\":{}}}";
+
+ final ContentSearchResponse response =
+ osSearchAPI.searchRaw(matchAll, false, systemUser, false);
+
+ assertNotNull("searchRaw must return a non-null ContentSearchResponse", response);
+ assertNotNull("hits must not be null on an empty-index response", response.hits());
+ assertNotNull("aggregations map must not be null", response.aggregations());
+ Logger.info(this,
+ "✅ test_searchRaw_matchAll_shouldReturnEmptyResultsWithNonNullStructure passed"
+ + " – totalHits=" + response.hits().totalHits().value());
+ }
+
+ /**
+ * Given scenario: A terms aggregation query against an empty working index.
+ * Expected: {@link ContentSearchResponse#aggregations()} contains the key {@code "entries"}
+ * (from the JSON {@code "aggs": {"entries": {"terms": ...}}}) and that bucket list
+ * is empty (no documents).
+ */
+ @Test
+ public void test_searchRaw_withTermsAgg_shouldReturnAggregationKey() throws Exception {
+ final String aggQuery =
+ "{\"size\":0,\"aggs\":{\"entries\":{\"terms\":{\"field\":\"contenttype_dotraw\",\"size\":100}}}}";
+
+ final ContentSearchResponse response =
+ osSearchAPI.searchRaw(aggQuery, false, systemUser, false);
+
+ assertNotNull("searchRaw must return a non-null response", response);
+ assertTrue(
+ "aggregations map must contain the 'entries' key even when result is empty",
+ response.aggregations().containsKey("entries"));
+ assertTrue("entries bucket must be empty for an empty index",
+ response.aggregations().get("entries").isEmpty());
+ Logger.info(this,
+ "✅ test_searchRaw_withTermsAgg_shouldReturnAggregationKey passed");
+ }
+
+ /**
+ * Given scenario: A match-all query against the live index.
+ * Expected: No exception is thrown and a valid response is returned.
+ */
+ @Test
+ public void test_searchRaw_liveIndex_shouldNotThrow() throws Exception {
+ final String matchAll = "{\"query\":{\"match_all\":{}}}";
+
+ final ContentSearchResponse response =
+ osSearchAPI.searchRaw(matchAll, true, systemUser, false);
+
+ assertNotNull("searchRaw against live index must return non-null response", response);
+ assertNotNull("hits must be non-null for live index query", response.hits());
+ Logger.info(this, "✅ test_searchRaw_liveIndex_shouldNotThrow passed");
+ }
+
+ /**
+ * Given scenario: A null query string is passed to {@link OSSearchAPIImpl#searchRaw}.
+ * Expected: A {@link com.dotmarketing.business.DotStateException} is thrown.
+ */
+ @Test(expected = com.dotmarketing.business.DotStateException.class)
+ public void test_searchRaw_nullQuery_shouldThrowDotStateException() throws Exception {
+ osSearchAPI.searchRaw(null, false, systemUser, false);
+ }
+
+ /**
+ * Given scenario: An empty string query is passed to {@link OSSearchAPIImpl#searchRaw}.
+ * Expected: A {@link com.dotmarketing.business.DotStateException} is thrown.
+ */
+ @Test(expected = com.dotmarketing.business.DotStateException.class)
+ public void test_searchRaw_emptyQuery_shouldThrowDotStateException() throws Exception {
+ osSearchAPI.searchRaw("", false, systemUser, false);
+ }
+
+ /**
+ * Given scenario: {@code user} is {@code null} and {@code respectFrontendRoles} is false.
+ * Expected: A {@link com.dotmarketing.exception.DotSecurityException} is thrown.
+ */
+ @Test(expected = com.dotmarketing.exception.DotSecurityException.class)
+ public void test_searchRaw_nullUserNoFrontendRoles_shouldThrowDotSecurityException()
+ throws Exception {
+ osSearchAPI.searchRaw("{\"query\":{\"match_all\":{}}}", false, null, false);
+ }
+
+ // =======================================================================
+ // Tests – search (full contentlet population)
+ // =======================================================================
+
+ /**
+ * Given scenario: A match-all JSON query with no documents in the index.
+ * Expected: {@link OSSearchAPIImpl#search} returns a non-null {@link ContentSearchResults}
+ * that is empty and reports zero total hits.
+ */
+ @Test
+ public void test_search_matchAll_emptyIndex_shouldReturnEmptyContentSearchResults()
+ throws Exception {
+
+ final String matchAll = "{\"query\":{\"match_all\":{}}}";
+
+ final ContentSearchResults results =
+ osSearchAPI.search(matchAll, false, systemUser, false);
+
+ assertNotNull("search must return non-null ContentSearchResults", results);
+ assertTrue("ContentSearchResults must be empty when index has no documents",
+ results.isEmpty());
+ assertNotNull("getResponse must return non-null ContentSearchResponse",
+ results.getResponse());
+ Logger.info(this,
+ "✅ test_search_matchAll_emptyIndex_shouldReturnEmptyContentSearchResults passed");
+ }
+
+ /**
+ * Given scenario: A match-all query is run with {@code respectFrontendRoles=true} and a null
+ * user — the anonymous-user path.
+ * Expected: No security exception; an empty but valid result is returned.
+ */
+ @Test
+ public void test_search_respectFrontendRoles_nullUser_shouldNotThrow() throws Exception {
+ final String matchAll = "{\"query\":{\"match_all\":{}}}";
+
+ final ContentSearchResults results =
+ osSearchAPI.search(matchAll, false, null, true);
+
+ assertNotNull("search with respectFrontendRoles=true must return non-null results", results);
+ Logger.info(this,
+ "✅ test_search_respectFrontendRoles_nullUser_shouldNotThrow passed");
+ }
+
+ // =======================================================================
+ // Tests – pagination
+ // =======================================================================
+
+ /**
+ * Given scenario: A search with explicit limit and offset against an empty index.
+ * Expected: No exception; result is empty and response structure is valid.
+ */
+ @Test
+ public void test_searchRelated_withLimitAndOffset_emptyIndex_shouldReturnValidStructure()
+ throws Exception {
+
+ final String matchAll = "{\"query\":{\"match_all\":{}}}";
+ // Use a fake identifier/inode so the method constructs the query and fires against OS
+ // (it will find 0 results because the index is empty)
+ final com.dotmarketing.portlets.contentlet.model.Contentlet fakeContent =
+ new com.dotmarketing.portlets.contentlet.model.Contentlet();
+ fakeContent.setIdentifier("fake-id-" + RUN_ID);
+ fakeContent.setInode("fake-inode-" + RUN_ID);
+
+ final ContentSearchResponse response = osSearchAPI.searchRelated(
+ fakeContent, "fakeRelationship", false, false, systemUser, false, 10, 0, null);
+
+ assertNotNull("searchRelated must return non-null response", response);
+ assertNotNull("hits must be non-null", response.hits());
+ Logger.info(this,
+ "✅ test_searchRelated_withLimitAndOffset_emptyIndex_shouldReturnValidStructure passed");
+ }
+
+ // =======================================================================
+ // Helpers
+ // =======================================================================
+
+ private synchronized void cleanupTestOsIndices() {
+ for (final String idx : List.of(IDX_LIVE, IDX_WORKING)) {
+ try {
+ if (osIndexAPI.indexExists(idx)) {
+ osIndexAPI.delete(idx);
+ }
+ } catch (Exception e) {
+ Logger.warn(this, "Cleanup: error removing OS index '" + idx + "': " + e.getMessage());
+ }
+ }
+ }
+
+ private void cleanupVersionedRows() {
+ try {
+ // Remove only the test-scoped index entries by their RUN_ID-tagged index names.
+ // Deleting by index_name avoids removing the entire OPENSEARCH_3X version row,
+ // which would break other tests running in parallel.
+ new DotConnect()
+ .setSQL("DELETE FROM indicies WHERE index_name LIKE ?")
+ .addParam("%" + RUN_ID + "%")
+ .loadResult();
+ } catch (Exception e) {
+ Logger.warn(this, "Cleanup: error removing versioned DB rows: " + e.getMessage());
+ }
+ }
+
+}
From cc0648098a510e1b2f5e89666a75a8dc286f46b2 Mon Sep 17 00:00:00 2001
From: fabrizzio-dotCMS
Date: Mon, 11 May 2026 10:55:45 -0600
Subject: [PATCH 02/14] test(os-search): add OSSearchAPIImplIntegrationTest to
OpenSearchUpgradeSuite
Co-Authored-By: Claude Sonnet 4.6
---
.../src/test/java/com/dotcms/OpenSearchUpgradeSuite.java | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/dotcms-integration/src/test/java/com/dotcms/OpenSearchUpgradeSuite.java b/dotcms-integration/src/test/java/com/dotcms/OpenSearchUpgradeSuite.java
index 0a8b93f95fe0..a275c4f9811a 100644
--- a/dotcms-integration/src/test/java/com/dotcms/OpenSearchUpgradeSuite.java
+++ b/dotcms-integration/src/test/java/com/dotcms/OpenSearchUpgradeSuite.java
@@ -9,6 +9,7 @@
import com.dotcms.content.index.opensearch.OSIndexAPIImplIntegrationTest;
import com.dotcms.content.index.opensearch.OSClientConfigTest;
import com.dotcms.content.index.opensearch.OSClientProviderIntegrationTest;
+import com.dotcms.content.index.opensearch.OSSearchAPIImplIntegrationTest;
import com.dotcms.junit.MainBaseSuite;
import org.junit.runner.RunWith;
import org.junit.runners.Suite.SuiteClasses;
@@ -40,7 +41,8 @@
ContentFactoryIndexOperationsOSIntegrationTest.class,
OSClientProviderIntegrationTest.class,
OSClientConfigTest.class,
- ContentletIndexAPIImplMigrationIT.class
+ ContentletIndexAPIImplMigrationIT.class,
+ OSSearchAPIImplIntegrationTest.class
})
public class OpenSearchUpgradeSuite {
}
\ No newline at end of file
From 2a936e975a384598e448347b03de722b71e39801 Mon Sep 17 00:00:00 2001
From: fabrizzio-dotCMS
Date: Mon, 11 May 2026 13:05:41 -0600
Subject: [PATCH 03/14] refactor(search): replace ContentletSearchAPIES
reimplementation with ESSearchAPIImpl adapter
Replace the reimplemented ContentletSearchAPIES with a proper adapter (ESSearchAPIImpl)
that delegates to the legacy com.dotcms.enterprise.priv.ESSearchAPIImpl and converts
ES-specific types to vendor-neutral DTOs via ContentSearchResponse.from().
The new class lives in com.dotcms.content.index.elasticsearch to mirror the symmetric
OSSearchAPIImpl in com.dotcms.content.index.opensearch, is annotated @ApplicationScoped
for CDI, and is resolved via CDIUtils in SearchAPIImpl.
Also fix OSSearchAPIImplIntegrationTest.setUp() to register cluster-prefixed index names
(via getNameWithClusterIDPrefix) in VersionedIndicesAPI so resolveIndex() targets the
correct OpenSearch index name and does not throw index_not_found_exception.
Co-Authored-By: Claude Sonnet 4.6
---
.../business/ContentletSearchAPIES.java | 313 ------------------
.../dotcms/content/index/SearchAPIImpl.java | 13 +-
.../index/elasticsearch/ESSearchAPIImpl.java | 160 +++++++++
.../OSSearchAPIImplIntegrationTest.java | 9 +-
4 files changed, 174 insertions(+), 321 deletions(-)
delete mode 100644 dotCMS/src/main/java/com/dotcms/content/elasticsearch/business/ContentletSearchAPIES.java
create mode 100644 dotCMS/src/main/java/com/dotcms/content/index/elasticsearch/ESSearchAPIImpl.java
diff --git a/dotCMS/src/main/java/com/dotcms/content/elasticsearch/business/ContentletSearchAPIES.java b/dotCMS/src/main/java/com/dotcms/content/elasticsearch/business/ContentletSearchAPIES.java
deleted file mode 100644
index a4f4bcf167a5..000000000000
--- a/dotCMS/src/main/java/com/dotcms/content/elasticsearch/business/ContentletSearchAPIES.java
+++ /dev/null
@@ -1,313 +0,0 @@
-package com.dotcms.content.elasticsearch.business;
-
-import static com.dotcms.content.elasticsearch.business.ContentFactoryIndexOperationsES.addBuilderSort;
-import static com.dotcms.content.elasticsearch.business.ESIndexAPI.INDEX_OPERATIONS_TIMEOUT_IN_MS;
-
-import com.dotcms.content.elasticsearch.util.RestHighLevelClientProvider;
-import com.dotcms.content.index.SearchAPI;
-import com.dotcms.content.index.domain.ContentSearchResponse;
-import com.dotcms.content.index.domain.ContentSearchResults;
-import com.dotcms.enterprise.priv.util.SearchSourceBuilderUtil;
-import com.dotmarketing.business.APILocator;
-import com.dotmarketing.business.DotStateException;
-import com.dotmarketing.business.Role;
-import com.dotmarketing.common.model.ContentletSearch;
-import com.dotmarketing.exception.DotDataException;
-import com.dotmarketing.exception.DotSecurityException;
-import com.dotmarketing.portlets.contentlet.model.Contentlet;
-import com.dotmarketing.util.Logger;
-import com.dotmarketing.util.StringUtils;
-import com.dotmarketing.util.UtilMethods;
-import com.dotmarketing.util.json.JSONArray;
-import com.dotmarketing.util.json.JSONException;
-import com.dotmarketing.util.json.JSONObject;
-import com.liferay.portal.model.User;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Map;
-import org.elasticsearch.action.search.SearchRequest;
-import org.elasticsearch.action.search.SearchResponse;
-import org.elasticsearch.client.RequestOptions;
-import org.elasticsearch.client.RestHighLevelClient;
-import org.elasticsearch.common.unit.TimeValue;
-import org.elasticsearch.search.SearchHit;
-import org.elasticsearch.search.builder.SearchSourceBuilder;
-
-/**
- * Elasticsearch implementation of {@link SearchAPI}.
- *
- *
Transitional class — lives in the {@code elasticsearch.*} package alongside the other
- * ES-specific implementations ({@link ESIndexAPI}, {@link ContentFactoryIndexOperationsES}).
- * Will be deleted when the ES→OS migration completes.
- *
- *
Implements the same search logic as the legacy {@code ESSearchAPIImpl} but returns
- * vendor-neutral {@link ContentSearchResponse} / {@link ContentSearchResults} DTOs instead
- * of {@code SearchResponse} / {@code ESSearchResults}.
- */
-public class ContentletSearchAPIES implements SearchAPI {
-
- // -------------------------------------------------------------------------
- // SearchAPI implementation
- // -------------------------------------------------------------------------
-
- @Override
- public ContentSearchResults search(
- final String query,
- final boolean live,
- final User user,
- final boolean respectFrontendRoles)
- throws DotSecurityException, DotDataException {
-
- final String normalized = query != null
- ? StringUtils.lowercaseStringExceptMatchingTokens(
- query, ESContentFactoryImpl.LUCENE_RESERVED_KEYWORDS_REGEX)
- : query;
-
- final ContentSearchResponse resp = searchRaw(normalized, live, user, respectFrontendRoles);
- final ContentSearchResults results = new ContentSearchResults(resp, new ArrayList<>());
- results.setQuery(normalized);
- results.setRewrittenQuery(normalized);
-
- if (resp.hits() == null) {
- return results;
- }
-
- final long start = System.currentTimeMillis();
- final List list = new ArrayList<>();
-
- for (final com.dotcms.content.index.domain.SearchHit sh : resp.hits()) {
- try {
- final Map sourceMap = sh.sourceAsMap();
- final ContentletSearch conwrapper = new ContentletSearch();
- conwrapper.setInode(sourceMap.get("inode").toString());
- list.add(conwrapper);
- } catch (final Exception e) {
- Logger.error(this, e.getMessage(), e);
- }
- }
-
- final List inodes = new ArrayList<>();
- for (final ContentletSearch conwrap : list) {
- inodes.add(conwrap.getInode());
- }
-
- final List contentlets = APILocator.getContentletAPIImpl().findContentlets(inodes);
- for (final Contentlet contentlet : contentlets) {
- if (contentlet.getInode() != null) {
- results.add(contentlet);
- }
- }
-
- results.setPopulationTook(System.currentTimeMillis() - start);
- return results;
- }
-
- @Override
- public ContentSearchResponse searchRaw(
- final String query,
- final boolean live,
- final User user,
- final boolean respectFrontendRoles)
- throws DotSecurityException, DotDataException {
-
- if (!UtilMethods.isSet(query)) {
- throw new DotStateException("Search query is null");
- }
-
- final JSONObject completeQueryJSON;
- try {
- completeQueryJSON = new JSONObject(query);
- completeQueryJSON.put("_source", new JSONArray("[identifier, inode]"));
- } catch (final JSONException e) {
- throw new DotStateException("Unable to parse the given query.", e);
- }
-
- final SearchResponse esResponse =
- executeSearch(completeQueryJSON, live, user, respectFrontendRoles, -1, -1, null);
- return ContentSearchResponse.from(esResponse);
- }
-
- @Override
- public ContentSearchResponse searchRelated(
- final String contentletIdentifier,
- final String relationshipName,
- final boolean pullParents,
- final boolean live,
- final User user,
- final boolean respectFrontendRoles)
- throws DotDataException, DotSecurityException {
-
- final Contentlet contentlet =
- APILocator.getContentletAPI().findContentletByIdentifierAnyLanguage(contentletIdentifier);
- return searchRelated(contentlet, relationshipName, pullParents, live, user,
- respectFrontendRoles, -1, -1, null);
- }
-
- @Override
- public ContentSearchResponse searchRelated(
- final Contentlet contentlet,
- final String relationshipName,
- final boolean pullParents,
- final boolean live,
- final User user,
- final boolean respectFrontendRoles)
- throws DotDataException, DotSecurityException {
-
- return searchRelated(contentlet, relationshipName, pullParents, live, user,
- respectFrontendRoles, -1, -1, null);
- }
-
- @Override
- public ContentSearchResponse searchRelated(
- final String contentletIdentifier,
- final String relationshipName,
- final boolean pullParents,
- final boolean live,
- final User user,
- final boolean respectFrontendRoles,
- final int limit,
- final int offset,
- final String sortBy)
- throws DotDataException, DotSecurityException {
-
- final Contentlet contentlet =
- APILocator.getContentletAPI().findContentletByIdentifierAnyLanguage(contentletIdentifier);
- return searchRelated(contentlet, relationshipName, pullParents, live, user,
- respectFrontendRoles, limit, offset, sortBy);
- }
-
- @Override
- public ContentSearchResponse searchRelated(
- final Contentlet contentlet,
- final String relationshipName,
- final boolean pullParents,
- final boolean live,
- final User user,
- final boolean respectFrontendRoles,
- final int limit,
- final int offset,
- final String sortBy)
- throws DotDataException, DotSecurityException {
-
- final JSONObject completeQueryJSON = buildRelatedQuery(contentlet, relationshipName, pullParents);
- final SearchResponse esResponse =
- executeSearch(completeQueryJSON, false, user, respectFrontendRoles, limit, offset, sortBy);
- return ContentSearchResponse.from(esResponse);
- }
-
- // -------------------------------------------------------------------------
- // Private helpers
- // -------------------------------------------------------------------------
-
- private JSONObject buildRelatedQuery(
- final Contentlet contentlet,
- final String relationshipName,
- final boolean pullParents) {
- final JSONObject criteriaMap = new JSONObject();
- try {
- if (pullParents) {
- criteriaMap.put("_source", "identifier");
- criteriaMap.put("query", new JSONObject().put("match",
- Map.of(relationshipName.toLowerCase(), contentlet.getIdentifier())));
- } else {
- criteriaMap.put("_source", relationshipName.toLowerCase());
- criteriaMap.put("query",
- new JSONObject().put("match", Map.of("inode", contentlet.getInode())));
- }
- } catch (final JSONException e) {
- throw new DotStateException("Unable to build related query.", e);
- }
- return new JSONObject(criteriaMap.toString());
- }
-
- /**
- * Executes a search against the active Elasticsearch index, applying permissions and sorting.
- */
- private SearchResponse executeSearch(
- final JSONObject queryJson,
- final boolean live,
- final User user,
- final boolean respectFrontendRoles,
- final int limit,
- final int offset,
- final String sortBy)
- throws DotSecurityException, DotDataException {
-
- final String indexToHit;
- try {
- final IndiciesInfo info = APILocator.getIndiciesAPI().loadIndicies();
- indexToHit = live ? info.getLive() : info.getWorking();
- } catch (final DotDataException ee) {
- Logger.fatal(this, "Can't get indices information", ee);
- throw new DotDataException("Unable to load index information", ee);
- }
-
- if (user == null && !respectFrontendRoles) {
- throw new DotSecurityException(
- "You must specify a user if you are not respecting frontend roles");
- }
-
- List roles = new ArrayList<>();
- boolean isAdmin = false;
- if (user != null) {
- if (!APILocator.getRoleAPI().doesUserHaveRole(user,
- APILocator.getRoleAPI().loadCMSAdminRole())) {
- roles = APILocator.getRoleAPI().loadRolesForUser(user.getUserId());
- } else {
- isAdmin = true;
- }
- }
-
- final RestHighLevelClient client = RestHighLevelClientProvider.getInstance().getClient();
- final SearchRequest request = new SearchRequest(indexToHit);
-
- final StringBuffer perms = new StringBuffer();
- if (!isAdmin && !queryJson.has("permissions:")) {
- APILocator.getContentletAPIImpl()
- .addPermissionsToQuery(perms, user, roles, respectFrontendRoles);
- }
-
- if (perms.length() > 0) {
- try {
- final JSONObject permissionsFilter = new JSONObject().put("query_string",
- new JSONObject().put("query", perms.toString().trim()));
- JSONArray boolFilters = new JSONArray("[" + permissionsFilter + "]");
-
- if (queryJson.has("query")) {
- final JSONObject currentQueryJSON =
- new JSONObject(queryJson.getJSONObject("query").toString());
- boolFilters = new JSONArray("[" + permissionsFilter + "," + currentQueryJSON + "]");
- }
-
- final JSONObject filteredJSON = new JSONObject().put("bool",
- new JSONObject().put("must", new JSONObject().put("bool",
- new JSONObject().put("must", boolFilters))));
- queryJson.put("query", filteredJSON);
- } catch (final JSONException e) {
- throw new DotStateException("Unable to parse the given query.", e);
- }
- }
-
- try {
- final SearchSourceBuilder searchSourceBuilder =
- SearchSourceBuilderUtil.getSearchSourceBuilder(queryJson.toString())
- .timeout(TimeValue.timeValueMillis(INDEX_OPERATIONS_TIMEOUT_IN_MS));
-
- if (limit > 0) {
- searchSourceBuilder.size(limit);
- }
- if (offset > 0) {
- searchSourceBuilder.from(offset);
- }
- if (UtilMethods.isSet(sortBy)) {
- addBuilderSort(sortBy, searchSourceBuilder);
- }
-
- request.source(searchSourceBuilder);
- return client.search(request, RequestOptions.DEFAULT);
- } catch (final IOException e) {
- throw new DotStateException(e.getMessage(), e);
- }
- }
-}
diff --git a/dotCMS/src/main/java/com/dotcms/content/index/SearchAPIImpl.java b/dotCMS/src/main/java/com/dotcms/content/index/SearchAPIImpl.java
index fa153fbe2e54..7c740887f94e 100644
--- a/dotCMS/src/main/java/com/dotcms/content/index/SearchAPIImpl.java
+++ b/dotCMS/src/main/java/com/dotcms/content/index/SearchAPIImpl.java
@@ -1,9 +1,9 @@
package com.dotcms.content.index;
import com.dotcms.cdi.CDIUtils;
-import com.dotcms.content.elasticsearch.business.ContentletSearchAPIES;
import com.dotcms.content.index.domain.ContentSearchResponse;
import com.dotcms.content.index.domain.ContentSearchResults;
+import com.dotcms.content.index.elasticsearch.ESSearchAPIImpl;
import com.dotcms.content.index.opensearch.OSSearchAPIImpl;
import com.dotcms.content.model.annotation.IndexLibraryIndependent;
import com.dotmarketing.exception.DotDataException;
@@ -28,29 +28,30 @@
* router only delegates to the read-path provider determined by {@link PhaseRouter#read}.
*
* @see PhaseRouter
- * @see ContentletSearchAPIES
+ * @see ESSearchAPIImpl
* @see OSSearchAPIImpl
*/
@IndexLibraryIndependent
public class SearchAPIImpl implements SearchAPI {
- private final ContentletSearchAPIES esImpl;
+ private final ESSearchAPIImpl esImpl;
private final OSSearchAPIImpl osImpl;
private final PhaseRouter router;
public SearchAPIImpl() {
- this(new ContentletSearchAPIES(), CDIUtils.getBeanThrows(OSSearchAPIImpl.class));
+ this(CDIUtils.getBeanThrows(ESSearchAPIImpl.class),
+ CDIUtils.getBeanThrows(OSSearchAPIImpl.class));
}
/** Package-private constructor for testing. */
- SearchAPIImpl(final ContentletSearchAPIES esImpl, final OSSearchAPIImpl osImpl) {
+ SearchAPIImpl(final ESSearchAPIImpl esImpl, final OSSearchAPIImpl osImpl) {
this.esImpl = esImpl;
this.osImpl = osImpl;
this.router = new PhaseRouter<>(esImpl, osImpl);
}
/** Direct access to the ES implementation (for testing / bootstrap checks). */
- public ContentletSearchAPIES esImpl() {
+ public ESSearchAPIImpl esImpl() {
return esImpl;
}
diff --git a/dotCMS/src/main/java/com/dotcms/content/index/elasticsearch/ESSearchAPIImpl.java b/dotCMS/src/main/java/com/dotcms/content/index/elasticsearch/ESSearchAPIImpl.java
new file mode 100644
index 000000000000..3b62e8350869
--- /dev/null
+++ b/dotCMS/src/main/java/com/dotcms/content/index/elasticsearch/ESSearchAPIImpl.java
@@ -0,0 +1,160 @@
+package com.dotcms.content.index.elasticsearch;
+
+import com.dotcms.content.elasticsearch.business.ESSearchResults;
+import com.dotcms.content.index.SearchAPI;
+import com.dotcms.content.index.domain.ContentSearchResponse;
+import com.dotcms.content.index.domain.ContentSearchResults;
+import com.dotcms.enterprise.ESSeachAPI;
+import com.dotmarketing.exception.DotDataException;
+import com.dotmarketing.exception.DotSecurityException;
+import com.dotmarketing.portlets.contentlet.model.Contentlet;
+import com.liferay.portal.model.User;
+import javax.enterprise.context.ApplicationScoped;
+
+/**
+ * Elasticsearch implementation of {@link SearchAPI}.
+ *
+ *
Adapter over the legacy {@link com.dotcms.enterprise.priv.ESSearchAPIImpl}: all search
+ * operations are delegated to that implementation and the ES-specific result types
+ * ({@code SearchResponse}, {@code ESSearchResults}) are converted to vendor-neutral DTOs
+ * via {@link ContentSearchResponse#from(org.elasticsearch.action.search.SearchResponse)}.
+ *
+ *
Transitional — will be deleted when the ES→OS migration completes (Phase 3 cutover).
+ *
+ * @see com.dotcms.content.index.opensearch.OSSearchAPIImpl symmetric OS counterpart
+ */
+@ApplicationScoped
+public class ESSearchAPIImpl implements SearchAPI {
+
+ private final ESSeachAPI delegate;
+
+ /** CDI constructor. */
+ public ESSearchAPIImpl() {
+ this(new com.dotcms.enterprise.priv.ESSearchAPIImpl());
+ }
+
+ /** Package-private for testing. */
+ ESSearchAPIImpl(final ESSeachAPI delegate) {
+ this.delegate = delegate;
+ }
+
+ // -------------------------------------------------------------------------
+ // SearchAPI — delegate and adapt
+ // -------------------------------------------------------------------------
+
+ @Override
+ public ContentSearchResults search(
+ final String query,
+ final boolean live,
+ final User user,
+ final boolean respectFrontendRoles)
+ throws DotSecurityException, DotDataException {
+
+ final ESSearchResults esResults = delegate.esSearch(query, live, user, respectFrontendRoles);
+ final ContentSearchResponse response = ContentSearchResponse.from(esResults.getResponse());
+ final ContentSearchResults results =
+ new ContentSearchResults(response, esResults.getContentlets());
+ results.setQuery(esResults.getQuery());
+ results.setRewrittenQuery(esResults.getRewrittenQuery());
+ results.setPopulationTook(esResults.getPopulationTook());
+ return results;
+ }
+
+ @Override
+ public ContentSearchResponse searchRaw(
+ final String query,
+ final boolean live,
+ final User user,
+ final boolean respectFrontendRoles)
+ throws DotSecurityException, DotDataException {
+
+ final org.elasticsearch.action.search.SearchResponse esResponse =
+ delegate.esSearchRaw(query, live, user, respectFrontendRoles);
+ if (esResponse == null) {
+ throw new DotDataException("ES search returned null — unable to load index information");
+ }
+ return ContentSearchResponse.from(esResponse);
+ }
+
+ @Override
+ public ContentSearchResponse searchRelated(
+ final String contentletIdentifier,
+ final String relationshipName,
+ final boolean pullParents,
+ final boolean live,
+ final User user,
+ final boolean respectFrontendRoles)
+ throws DotDataException, DotSecurityException {
+
+ final org.elasticsearch.action.search.SearchResponse esResponse =
+ delegate.esSearchRelated(contentletIdentifier, relationshipName,
+ pullParents, live, user, respectFrontendRoles);
+ if (esResponse == null) {
+ throw new DotDataException("ES search returned null — unable to load index information");
+ }
+ return ContentSearchResponse.from(esResponse);
+ }
+
+ @Override
+ public ContentSearchResponse searchRelated(
+ final Contentlet contentlet,
+ final String relationshipName,
+ final boolean pullParents,
+ final boolean live,
+ final User user,
+ final boolean respectFrontendRoles)
+ throws DotDataException, DotSecurityException {
+
+ final org.elasticsearch.action.search.SearchResponse esResponse =
+ delegate.esSearchRelated(contentlet, relationshipName,
+ pullParents, live, user, respectFrontendRoles);
+ if (esResponse == null) {
+ throw new DotDataException("ES search returned null — unable to load index information");
+ }
+ return ContentSearchResponse.from(esResponse);
+ }
+
+ @Override
+ public ContentSearchResponse searchRelated(
+ final String contentletIdentifier,
+ final String relationshipName,
+ final boolean pullParents,
+ final boolean live,
+ final User user,
+ final boolean respectFrontendRoles,
+ final int limit,
+ final int offset,
+ final String sortBy)
+ throws DotDataException, DotSecurityException {
+
+ final org.elasticsearch.action.search.SearchResponse esResponse =
+ delegate.esSearchRelated(contentletIdentifier, relationshipName,
+ pullParents, live, user, respectFrontendRoles, limit, offset, sortBy);
+ if (esResponse == null) {
+ throw new DotDataException("ES search returned null — unable to load index information");
+ }
+ return ContentSearchResponse.from(esResponse);
+ }
+
+ @Override
+ public ContentSearchResponse searchRelated(
+ final Contentlet contentlet,
+ final String relationshipName,
+ final boolean pullParents,
+ final boolean live,
+ final User user,
+ final boolean respectFrontendRoles,
+ final int limit,
+ final int offset,
+ final String sortBy)
+ throws DotDataException, DotSecurityException {
+
+ final org.elasticsearch.action.search.SearchResponse esResponse =
+ delegate.esSearchRelated(contentlet, relationshipName,
+ pullParents, live, user, respectFrontendRoles, limit, offset, sortBy);
+ if (esResponse == null) {
+ throw new DotDataException("ES search returned null — unable to load index information");
+ }
+ return ContentSearchResponse.from(esResponse);
+ }
+}
diff --git a/dotcms-integration/src/test/java/com/dotcms/content/index/opensearch/OSSearchAPIImplIntegrationTest.java b/dotcms-integration/src/test/java/com/dotcms/content/index/opensearch/OSSearchAPIImplIntegrationTest.java
index b840fbbeb6f9..c3ee798ba8f3 100644
--- a/dotcms-integration/src/test/java/com/dotcms/content/index/opensearch/OSSearchAPIImplIntegrationTest.java
+++ b/dotcms-integration/src/test/java/com/dotcms/content/index/opensearch/OSSearchAPIImplIntegrationTest.java
@@ -101,11 +101,16 @@ public void setUp() throws Exception {
osIndexAPI.createIndex(IDX_LIVE, 1);
osIndexAPI.createIndex(IDX_WORKING, 1);
+ // createIndex() stores the cluster-prefixed name in OpenSearch; resolveIndex()
+ // must use the same prefixed name, otherwise the search hits a non-existent index.
+ final String fullLive = osIndexAPI.getNameWithClusterIDPrefix(IDX_LIVE);
+ final String fullWorking = osIndexAPI.getNameWithClusterIDPrefix(IDX_WORKING);
+
versionedIndicesAPI.saveIndices(
VersionedIndicesImpl.builder()
.version(DEFAULT_OS_VERSION)
- .live(IDX_LIVE)
- .working(IDX_WORKING)
+ .live(fullLive)
+ .working(fullWorking)
.build());
// Ensure resolveIndex() reads the freshly saved rows, not a cached prior state.
From beeb2627058ad8cf85fbfd27ca2c637588eabc44 Mon Sep 17 00:00:00 2001
From: fabrizzio-dotCMS
Date: Mon, 11 May 2026 14:37:09 -0600
Subject: [PATCH 04/14] refactor(search): deprecate esSearch/esSearchRaw in
ContentletAPI and migrate callers to neutral API
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Add vendor-neutral default methods search() and searchRaw() to ContentletAPI, both delegating
to APILocator.getSearchAPI() (the phase-aware router). Mark esSearch() and esSearchRaw() as
@Deprecated pointing to the new methods.
Migrate all call sites except ESContentResourcePortlet line 262 (deferred — that caller relies
on SearchResponse.toString() producing ES wire-format JSON; marked with FIXME(OS-cutover)):
- PageResource: esSearch → search, ESSearchResults → ContentSearchResults
- WorkflowHelper: replace ParsedStringTerms aggregations loop with AggregationBucket map iteration
- ESMappingAPITest: esSearch → search (3 callers); esSearchRaw → searchRaw with direct
aggregations map access instead of raw.toString() JSON parsing
- ES6UpgradeTest: esSearch → search; getAggregations().asList() → getAggregations().isEmpty()
- ContentletAPITest: esSearchRaw → searchRaw; getHits().getHits()[i].getId() → hits().iterator().next().id()
- PageResourceTest: update mocks from ESSearchResults to ContentSearchResults
ESContentletAPIImpl lines 354/360 (getEsSearchAPI() calls) remain unchanged — they serve
the deprecated interface declarations and will be removed together with those declarations
once ESContentResourcePortlet is also migrated.
Co-Authored-By: Claude Sonnet 4.6
---
.../dotcms/rest/api/v1/page/PageResource.java | 10 ++--
.../ESContentResourcePortlet.java | 2 +
.../workflow/helper/WorkflowHelper.java | 30 ++++-------
.../contentlet/business/ContentletAPI.java | 54 ++++++++++++++-----
.../business/ES6UpgradeTest.java | 7 +--
.../business/ESMappingAPITest.java | 21 ++++----
.../rest/api/v1/page/PageResourceTest.java | 21 ++++----
.../business/ContentletAPITest.java | 10 ++--
8 files changed, 90 insertions(+), 65 deletions(-)
diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/page/PageResource.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/page/PageResource.java
index 6297731ec3cd..cbdb92dd672b 100644
--- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/page/PageResource.java
+++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/page/PageResource.java
@@ -39,7 +39,7 @@
import org.glassfish.jersey.server.JSONP;
import com.dotcms.api.web.HttpServletRequestThreadLocal;
-import com.dotcms.content.elasticsearch.business.ESSearchResults;
+import com.dotcms.content.index.domain.ContentSearchResults;
import com.dotcms.contenttype.business.ContentTypeAPI;
import com.dotcms.contenttype.model.type.BaseContentType;
import com.dotcms.contenttype.model.type.ContentType;
@@ -1057,7 +1057,7 @@ public Response searchPage(
final String esQuery = getPageByPathESQuery(path);
- final ESSearchResults esresult = esapi.esSearch(esQuery, live, user, live);
+ final ContentSearchResults esresult = esapi.search(esQuery, live, user, live);
final Set