/*
* (C) Copyright 2014-2016 Nuxeo SA (http://nuxeo.com/) and others.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* Contributors:
* bdelbosc
*/
package org.nuxeo.elasticsearch.query;
import static org.nuxeo.ecm.core.api.security.SecurityConstants.UNSUPPORTED_ACL;
import static org.nuxeo.elasticsearch.ElasticSearchConstants.ACL_FIELD;
import static org.nuxeo.elasticsearch.ElasticSearchConstants.FETCH_DOC_FROM_ES_PROPERTY;
import java.security.Principal;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.elasticsearch.action.search.SearchRequestBuilder;
import org.elasticsearch.action.search.SearchResponse;
import org.elasticsearch.index.query.BoolQueryBuilder;
import org.elasticsearch.index.query.QueryBuilder;
import org.elasticsearch.index.query.QueryBuilders;
import org.elasticsearch.search.aggregations.AbstractAggregationBuilder;
import org.elasticsearch.search.aggregations.bucket.filter.FilterAggregationBuilder;
import org.elasticsearch.search.sort.FieldSortBuilder;
import org.elasticsearch.search.sort.SortBuilder;
import org.elasticsearch.search.sort.SortOrder;
import org.nuxeo.ecm.core.api.CoreSession;
import org.nuxeo.ecm.core.api.NuxeoPrincipal;
import org.nuxeo.ecm.core.api.SortInfo;
import org.nuxeo.ecm.core.schema.SchemaManager;
import org.nuxeo.ecm.core.schema.types.Type;
import org.nuxeo.ecm.core.security.SecurityService;
import org.nuxeo.ecm.platform.query.api.Aggregate;
import org.nuxeo.ecm.platform.query.api.Bucket;
import org.nuxeo.elasticsearch.ElasticSearchConstants;
import org.nuxeo.elasticsearch.aggregate.AggregateEsBase;
import org.nuxeo.elasticsearch.api.EsResult;
import org.nuxeo.elasticsearch.fetcher.EsFetcher;
import org.nuxeo.elasticsearch.fetcher.Fetcher;
import org.nuxeo.elasticsearch.fetcher.VcsFetcher;
import org.nuxeo.runtime.api.Framework;
/**
* Elasticsearch query builder for the Nuxeo ES api.
*
* @since 5.9.5
*/
public class NxQueryBuilder {
private static final int DEFAULT_LIMIT = 10;
private int limit = DEFAULT_LIMIT;
private static final String AGG_FILTER_SUFFIX = "_filter";
private final CoreSession session;
private final List<SortInfo> sortInfos = new ArrayList<>();
private final List<String> repositories = new ArrayList<>();
private final List<AggregateEsBase<? extends Bucket>> aggregates = new ArrayList<>();
private int offset = 0;
private String nxql;
private org.elasticsearch.index.query.QueryBuilder esQueryBuilder;
private boolean fetchFromElasticsearch = false;
private boolean searchOnAllRepo = false;
private String[] selectFields = { ElasticSearchConstants.ID_FIELD };
private Map<String, Type> selectFieldsAndTypes;
private boolean returnsDocuments = true;
private boolean esOnly = false;
private List<String> highlightFields;
public NxQueryBuilder(CoreSession coreSession) {
session = coreSession;
repositories.add(coreSession.getRepositoryName());
fetchFromElasticsearch = Boolean.parseBoolean(Framework.getProperty(FETCH_DOC_FROM_ES_PROPERTY, "false"));
}
public static String getAggregateFilterId(Aggregate agg) {
return agg.getId() + AGG_FILTER_SUFFIX;
}
/**
* No more than that many documents will be returned. Default to {DEFAULT_LIMIT}. Since Nuxeo 8.4 and ES 2.x, we can
* not give -1 to this method as the default configuration on ES allows to have a search window of 10000 documents
* at maximum. This settings could be changed on ES by changing {index.max_result_window}, but it is preferable to
* use the scan & scroll API.
*/
public NxQueryBuilder limit(int limit) {
// For compatibility only, deprecated since 8.4
if (limit < 0) {
limit = Integer.MAX_VALUE;
}
this.limit = limit;
return this;
}
/**
* Says to skip that many documents before beginning to return documents. If both offset and limit appear, then
* offset documents are skipped before starting to count the limit documents that are returned.
*/
public NxQueryBuilder offset(int offset) {
this.offset = offset;
return this;
}
public NxQueryBuilder addSort(SortInfo sortInfo) {
sortInfos.add(sortInfo);
return this;
}
public NxQueryBuilder addSort(SortInfo[] sortInfos) {
if (sortInfos != null && sortInfos.length > 0) {
Collections.addAll(this.sortInfos, sortInfos);
}
return this;
}
/**
* Build the query from a NXQL string. You should either use nxql, either esQuery, not both.
*/
public NxQueryBuilder nxql(String nxql) {
this.nxql = nxql;
this.esQueryBuilder = null;
return this;
}
/**
* Build the query using the Elasticsearch QueryBuilder API. You should either use nxql, either esQuery, not both.
*/
public NxQueryBuilder esQuery(QueryBuilder queryBuilder) {
esQueryBuilder = addSecurityFilter(queryBuilder);
nxql = null;
return this;
}
/**
* Ask for the Elasticsearch _source field, use it to build documents.
*/
public NxQueryBuilder fetchFromElasticsearch() {
fetchFromElasticsearch = true;
return this;
}
/**
* Fetch the documents using VCS (database) engine. This is done by default
*/
public NxQueryBuilder fetchFromDatabase() {
fetchFromElasticsearch = false;
return this;
}
/**
* Don't return document model list, aggregates or rows, only the original Elasticsearch response is accessible from
* {@link EsResult#getElasticsearchResponse()}
*
* @since 7.3
*/
public NxQueryBuilder onlyElasticsearchResponse() {
esOnly = true;
return this;
}
public NxQueryBuilder addAggregate(AggregateEsBase<? extends Bucket> aggregate) {
aggregates.add(aggregate);
return this;
}
public NxQueryBuilder addAggregates(List<AggregateEsBase<? extends Bucket>> aggregates) {
if (aggregates != null && !aggregates.isEmpty()) {
this.aggregates.addAll(aggregates);
}
return this;
}
/**
* @since 9.1
*/
public NxQueryBuilder highlight(List<String> highlightFields) {
this.highlightFields = highlightFields;
return this;
}
public int getLimit() {
return limit;
}
public int getOffset() {
return offset;
}
public List<SortInfo> getSortInfos() {
return sortInfos;
}
public String getNxql() {
return nxql;
}
public boolean isFetchFromElasticsearch() {
return fetchFromElasticsearch;
}
public CoreSession getSession() {
return session;
}
/**
* Get the Elasticsearch queryBuilder. Note that it returns only the query part without order, limits nor
* aggregates, use the udpateRequest to get the full request.
*/
public QueryBuilder makeQuery() {
if (esQueryBuilder == null) {
if (nxql != null) {
esQueryBuilder = NxqlQueryConverter.toESQueryBuilder(nxql, session);
// handle the built-in order by clause
if (nxql.toLowerCase().contains("order by")) {
List<SortInfo> builtInSortInfos = NxqlQueryConverter.getSortInfo(nxql);
sortInfos.addAll(builtInSortInfos);
}
if (nxqlHasSelectClause(nxql)) {
selectFieldsAndTypes = NxqlQueryConverter.getSelectClauseFields(nxql);
Set<String> keySet = selectFieldsAndTypes.keySet();
selectFields = keySet.toArray(new String[keySet.size()]);
returnsDocuments = false;
}
esQueryBuilder = addSecurityFilter(esQueryBuilder);
}
}
return esQueryBuilder;
}
protected boolean nxqlHasSelectClause(String nxql) {
String lowerNxql = nxql.toLowerCase();
return lowerNxql.startsWith("select") && !lowerNxql.startsWith("select * from");
}
public SortBuilder[] getSortBuilders() {
SortBuilder[] ret;
if (sortInfos.isEmpty()) {
return new SortBuilder[0];
}
ret = new SortBuilder[sortInfos.size()];
int i = 0;
for (SortInfo sortInfo : sortInfos) {
String fieldType = guessFieldType(sortInfo.getSortColumn());
ret[i++] = new FieldSortBuilder(sortInfo.getSortColumn()).order(
sortInfo.getSortAscending() ? SortOrder.ASC : SortOrder.DESC)
.unmappedType(fieldType);
}
return ret;
}
protected String guessFieldType(String field) {
String fieldType;
try {
SchemaManager schemaManager = Framework.getService(SchemaManager.class);
fieldType = schemaManager.getField(field).getType().getName();
} catch (NullPointerException e) {
// probably an internal field without schema
fieldType = "string";
}
switch (fieldType) {
case "integer":
case "long":
case "boolean":
case "date":
case "string":
return fieldType;
}
return "string";
}
protected QueryBuilder getAggregateFilter() {
BoolQueryBuilder ret = QueryBuilders.boolQuery();
for (AggregateEsBase agg : aggregates) {
QueryBuilder filter = agg.getEsFilter();
if (filter != null) {
ret.must(filter);
}
}
if (!ret.hasClauses()) {
return null;
}
return ret;
}
protected QueryBuilder getAggregateFilterExceptFor(String id) {
BoolQueryBuilder ret = QueryBuilders.boolQuery();
for (AggregateEsBase agg : aggregates) {
if (!agg.getId().equals(id)) {
QueryBuilder filter = agg.getEsFilter();
if (filter != null) {
ret.must(filter);
}
}
}
if (!ret.hasClauses()) {
return QueryBuilders.matchAllQuery();
}
return ret;
}
public List<AggregateEsBase<? extends Bucket>> getAggregates() {
return aggregates;
}
public List<FilterAggregationBuilder> getEsAggregates() {
List<FilterAggregationBuilder> ret = new ArrayList<>(aggregates.size());
for (AggregateEsBase agg : aggregates) {
FilterAggregationBuilder fagg = new FilterAggregationBuilder(getAggregateFilterId(agg));
fagg.filter(getAggregateFilterExceptFor(agg.getId()));
fagg.subAggregation(agg.getEsAggregate());
ret.add(fagg);
}
return ret;
}
public void updateRequest(SearchRequestBuilder request) {
// Set limits
request.setFrom(getOffset()).setSize(getLimit());
// Build query with security checks
request.setQuery(makeQuery());
// Add sort
for (SortBuilder sortBuilder : getSortBuilders()) {
request.addSort(sortBuilder);
}
// Add Aggregate
for (AbstractAggregationBuilder aggregate : getEsAggregates()) {
request.addAggregation(aggregate);
}
// Add Aggregate post filter
QueryBuilder aggFilter = getAggregateFilter();
if (aggFilter != null) {
request.setPostFilter(aggFilter);
}
// Add highlighting
if (highlightFields != null && !highlightFields.isEmpty()) {
for (String field : highlightFields) {
request.addHighlightedField(field);
}
request.setHighlighterRequireFieldMatch(false);
}
// Fields selection
if (!isFetchFromElasticsearch()) {
request.addFields(getSelectFields());
}
}
protected QueryBuilder addSecurityFilter(QueryBuilder query) {
Principal principal = session.getPrincipal();
if (principal == null
|| (principal instanceof NuxeoPrincipal && ((NuxeoPrincipal) principal).isAdministrator())) {
return query;
}
String[] principals = SecurityService.getPrincipalsToCheck(principal);
// we want an ACL that match principals but we discard
// unsupported ACE that contains negative ACE
QueryBuilder aclFilter = QueryBuilders.boolQuery()
.must(QueryBuilders.termsQuery(ACL_FIELD, principals))
.mustNot(QueryBuilders.termsQuery(ACL_FIELD, UNSUPPORTED_ACL));
return QueryBuilders.boolQuery().must(query).filter(aclFilter);
}
/**
* Add a specific repository to search. Default search is done on the session repository only.
*
* @since 6.0
*/
public NxQueryBuilder addSearchRepository(String repositoryName) {
repositories.add(repositoryName);
return this;
}
/**
* Search on all available repositories.
*
* @since 6.0
*/
public NxQueryBuilder searchOnAllRepositories() {
searchOnAllRepo = true;
return this;
}
/**
* Return the list of repositories to search, or an empty list to search on all available repositories;
*
* @since 6.0
*/
public List<String> getSearchRepositories() {
if (searchOnAllRepo) {
return Collections.<String> emptyList();
}
return repositories;
}
/**
* @since 6.0
*/
public Fetcher getFetcher(SearchResponse response, Map<String, String> repoNames) {
if (isFetchFromElasticsearch()) {
return new EsFetcher(session, response, repoNames);
}
return new VcsFetcher(session, response, repoNames);
}
/**
* @since 7.2
*/
public String[] getSelectFields() {
return selectFields;
}
/**
* @since 7.2
*/
public Map<String, Type> getSelectFieldsAndTypes() {
return selectFieldsAndTypes;
}
/**
* @since 7.2
*/
public boolean returnsDocuments() {
if (esOnly) {
return false;
}
return returnsDocuments;
}
public boolean returnsRows() {
if (esOnly) {
return false;
}
return !returnsDocuments;
}
}