001/*
002 * (C) Copyright 2014-2016 Nuxeo SA (http://nuxeo.com/) and others.
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 *     http://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 *
016 * Contributors:
017 *     bdelbosc
018 */
019package org.nuxeo.elasticsearch.query;
020
021import static org.nuxeo.ecm.core.api.security.SecurityConstants.UNSUPPORTED_ACL;
022import static org.nuxeo.elasticsearch.ElasticSearchConstants.ACL_FIELD;
023import static org.nuxeo.elasticsearch.ElasticSearchConstants.ES_SCORE_FIELD;
024import static org.nuxeo.elasticsearch.ElasticSearchConstants.FETCH_DOC_FROM_ES_PROPERTY;
025
026import java.util.ArrayList;
027import java.util.Collections;
028import java.util.List;
029import java.util.Map;
030import java.util.Set;
031
032import org.elasticsearch.action.search.SearchResponse;
033import org.elasticsearch.index.query.BoolQueryBuilder;
034import org.elasticsearch.index.query.QueryBuilder;
035import org.elasticsearch.index.query.QueryBuilders;
036import org.elasticsearch.search.aggregations.AbstractAggregationBuilder;
037import org.elasticsearch.search.aggregations.Aggregation;
038import org.elasticsearch.search.aggregations.bucket.filter.FilterAggregationBuilder;
039import org.elasticsearch.search.builder.SearchSourceBuilder;
040import org.elasticsearch.search.fetch.subphase.highlight.HighlightBuilder;
041import org.elasticsearch.search.sort.FieldSortBuilder;
042import org.elasticsearch.search.sort.SortBuilder;
043import org.elasticsearch.search.sort.SortOrder;
044import org.nuxeo.ecm.core.api.CoreSession;
045import org.nuxeo.ecm.core.api.NuxeoPrincipal;
046import org.nuxeo.ecm.core.api.SortInfo;
047import org.nuxeo.ecm.core.schema.SchemaManager;
048import org.nuxeo.ecm.core.schema.types.Type;
049import org.nuxeo.ecm.core.security.SecurityService;
050import org.nuxeo.ecm.platform.query.api.Aggregate;
051import org.nuxeo.ecm.platform.query.api.Bucket;
052import org.nuxeo.elasticsearch.ElasticSearchConstants;
053import org.nuxeo.elasticsearch.aggregate.AggregateEsBase;
054import org.nuxeo.elasticsearch.api.EsResult;
055import org.nuxeo.elasticsearch.fetcher.EsFetcher;
056import org.nuxeo.elasticsearch.fetcher.Fetcher;
057import org.nuxeo.elasticsearch.fetcher.VcsFetcher;
058import org.nuxeo.runtime.api.Framework;
059
060/**
061 * Elasticsearch query builder for the Nuxeo ES api.
062 *
063 * @since 5.9.5
064 */
065public class NxQueryBuilder {
066
067    private static final int DEFAULT_LIMIT = 10;
068
069    private static final String AGG_FILTER_SUFFIX = "_filter";
070
071    private final CoreSession session;
072
073    private final List<SortInfo> sortInfos = new ArrayList<>();
074
075    private final List<String> repositories = new ArrayList<>();
076
077    private final List<AggregateEsBase<Aggregation, Bucket>> aggregates = new ArrayList<>();
078
079    private int limit = DEFAULT_LIMIT;
080
081    private int offset = 0;
082
083    private String nxql;
084
085    private org.elasticsearch.index.query.QueryBuilder esQueryBuilder;
086
087    private boolean fetchFromElasticsearch = false;
088
089    private boolean searchOnAllRepo = false;
090
091    private String[] selectFields = { ElasticSearchConstants.ID_FIELD };
092
093    private Map<String, Type> selectFieldsAndTypes;
094
095    private boolean returnsDocuments = true;
096
097    private boolean esOnly = false;
098
099    private List<String> highlightFields;
100
101    private EsFetcher.HitDocConsumer hitDocConsumer;
102
103    public NxQueryBuilder(CoreSession coreSession) {
104        session = coreSession;
105        repositories.add(coreSession.getRepositoryName());
106        fetchFromElasticsearch = Boolean.parseBoolean(Framework.getProperty(FETCH_DOC_FROM_ES_PROPERTY, "false"));
107    }
108
109    public static String getAggregateFilterId(Aggregate agg) {
110        return agg.getId() + AGG_FILTER_SUFFIX;
111    }
112
113    /**
114     * No more than that many documents will be returned. Default to {DEFAULT_LIMIT}. Since Nuxeo 8.4 and ES 2.x, we can
115     * not give -1 to this method as the default configuration on ES allows to have a search window of 10000 documents
116     * at maximum. This settings could be changed on ES by changing {index.max_result_window}, but it is preferable to
117     * use the scan & scroll API.
118     */
119    public NxQueryBuilder limit(int limit) {
120        // For compatibility only, deprecated since 8.4
121        if (limit < 0) {
122            limit = Integer.MAX_VALUE;
123        }
124        this.limit = limit;
125        return this;
126    }
127
128    /**
129     * Says to skip that many documents before beginning to return documents. If both offset and limit appear, then
130     * offset documents are skipped before starting to count the limit documents that are returned.
131     */
132    public NxQueryBuilder offset(int offset) {
133        this.offset = offset;
134        return this;
135    }
136
137    public NxQueryBuilder addSort(SortInfo sortInfo) {
138        sortInfos.add(sortInfo);
139        return this;
140    }
141
142    public NxQueryBuilder addSort(SortInfo[] sortInfos) {
143        if (sortInfos != null && sortInfos.length > 0) {
144            Collections.addAll(this.sortInfos, sortInfos);
145        }
146        return this;
147    }
148
149    /**
150     * Build the query from a NXQL string. You should either use nxql, either esQuery, not both.
151     */
152    public NxQueryBuilder nxql(String nxql) {
153        this.nxql = nxql;
154        this.esQueryBuilder = null;
155        return this;
156    }
157
158    /**
159     * Build the query using the Elasticsearch QueryBuilder API. You should either use nxql, either esQuery, not both.
160     */
161    public NxQueryBuilder esQuery(QueryBuilder queryBuilder) {
162        esQueryBuilder = addSecurityFilter(queryBuilder);
163        nxql = null;
164        return this;
165    }
166
167    /**
168     * Ask for the Elasticsearch _source field, use it to build documents.
169     */
170    public NxQueryBuilder fetchFromElasticsearch() {
171        fetchFromElasticsearch = true;
172        return this;
173    }
174
175    /**
176     * If search results are found, use this SearchHit and DocumentModel consumer on each hit.
177     * @since 10.2
178     */
179    public NxQueryBuilder hitDocConsumer(EsFetcher.HitDocConsumer consumer) {
180        hitDocConsumer = consumer;
181        return this;
182    }
183
184    /**
185     * Fetch the documents using VCS (database) engine. This is done by default
186     */
187    public NxQueryBuilder fetchFromDatabase() {
188        fetchFromElasticsearch = false;
189        return this;
190    }
191
192    /**
193     * Don't return document model list, aggregates or rows, only the original Elasticsearch response is accessible from
194     * {@link EsResult#getElasticsearchResponse()}
195     *
196     * @since 7.3
197     */
198    public NxQueryBuilder onlyElasticsearchResponse() {
199        esOnly = true;
200        return this;
201    }
202
203    @SuppressWarnings("unchecked")
204    public NxQueryBuilder addAggregate(AggregateEsBase<? extends Aggregation, ? extends Bucket> aggregate) {
205        aggregates.add((AggregateEsBase<Aggregation, Bucket>) aggregate);
206        return this;
207    }
208
209    public NxQueryBuilder addAggregates(List<AggregateEsBase<? extends Aggregation, ? extends Bucket>> aggregates) {
210        if (aggregates != null && !aggregates.isEmpty()) {
211            aggregates.forEach(this::addAggregate);
212        }
213        return this;
214    }
215
216    /**
217     * @since 9.1
218     */
219    public NxQueryBuilder highlight(List<String> highlightFields) {
220        this.highlightFields = highlightFields;
221        return this;
222    }
223
224    public int getLimit() {
225        return limit;
226    }
227
228    public int getOffset() {
229        return offset;
230    }
231
232    public List<SortInfo> getSortInfos() {
233        return sortInfos;
234    }
235
236    public String getNxql() {
237        return nxql;
238    }
239
240    public boolean isFetchFromElasticsearch() {
241        return fetchFromElasticsearch;
242    }
243
244    public CoreSession getSession() {
245        return session;
246    }
247
248    /**
249     * Get the Elasticsearch queryBuilder. Note that it returns only the query part without order, limits nor
250     * aggregates, use the udpateRequest to get the full request.
251     */
252    public QueryBuilder makeQuery() {
253        if (esQueryBuilder == null) {
254            if (nxql != null) {
255                esQueryBuilder = NxqlQueryConverter.toESQueryBuilder(nxql, session);
256                // handle the built-in order by clause
257                if (nxql.toLowerCase().contains("order by")) {
258                    List<SortInfo> builtInSortInfos = NxqlQueryConverter.getSortInfo(nxql);
259                    sortInfos.addAll(builtInSortInfos);
260                }
261                if (nxqlHasSelectClause(nxql)) {
262                    selectFieldsAndTypes = NxqlQueryConverter.getSelectClauseFields(nxql);
263                    Set<String> keySet = selectFieldsAndTypes.keySet();
264                    selectFields = keySet.toArray(new String[keySet.size()]);
265                    returnsDocuments = false;
266                }
267                esQueryBuilder = addSecurityFilter(esQueryBuilder);
268            }
269        }
270        return esQueryBuilder;
271    }
272
273    protected boolean nxqlHasSelectClause(String nxql) {
274        String lowerNxql = nxql.toLowerCase();
275        return lowerNxql.startsWith("select") && !lowerNxql.startsWith("select * from");
276    }
277
278    public SortBuilder[] getSortBuilders() {
279        SortBuilder[] ret;
280        if (sortInfos.isEmpty()) {
281            return new SortBuilder[0];
282        }
283        ret = new SortBuilder[sortInfos.size()];
284        int i = 0;
285        for (SortInfo sortInfo : sortInfos) {
286            String fieldType = guessFieldType(sortInfo.getSortColumn());
287            ret[i++] = new FieldSortBuilder(sortInfo.getSortColumn())
288                                                                     .order(sortInfo.getSortAscending() ? SortOrder.ASC
289                                                                             : SortOrder.DESC)
290                                                                     .unmappedType(fieldType);
291        }
292        return ret;
293    }
294
295    protected String guessFieldType(String field) {
296        String fieldType;
297        if (ES_SCORE_FIELD.equals(field)) {
298            // this special field should not have an unmappedType
299            return null;
300        }
301        try {
302            SchemaManager schemaManager = Framework.getService(SchemaManager.class);
303            fieldType = schemaManager.getField(field).getType().getName();
304        } catch (NullPointerException e) {
305            // probably an internal field without schema
306            fieldType = "keyword";
307        }
308        switch (fieldType) {
309        case "integer":
310        case "long":
311        case "boolean":
312        case "date":
313            return fieldType;
314        }
315        return "keyword";
316    }
317
318    protected QueryBuilder getAggregateFilter() {
319        BoolQueryBuilder ret = QueryBuilders.boolQuery();
320        for (AggregateEsBase agg : aggregates) {
321            QueryBuilder filter = agg.getEsFilter();
322            if (filter != null) {
323                ret.must(filter);
324            }
325        }
326        if (!ret.hasClauses()) {
327            return null;
328        }
329        return ret;
330    }
331
332    protected QueryBuilder getAggregateFilterExceptFor(String id) {
333        BoolQueryBuilder ret = QueryBuilders.boolQuery();
334        for (AggregateEsBase agg : aggregates) {
335            if (!agg.getId().equals(id)) {
336                QueryBuilder filter = agg.getEsFilter();
337                if (filter != null) {
338                    ret.must(filter);
339                }
340            }
341        }
342        if (!ret.hasClauses()) {
343            return QueryBuilders.matchAllQuery();
344        }
345        return ret;
346    }
347
348    public List<AggregateEsBase<Aggregation, Bucket>> getAggregates() {
349        return aggregates;
350    }
351
352    public List<FilterAggregationBuilder> getEsAggregates() {
353        List<FilterAggregationBuilder> ret = new ArrayList<>(aggregates.size());
354        for (AggregateEsBase agg : aggregates) {
355            FilterAggregationBuilder fagg = null;
356            fagg = new FilterAggregationBuilder(getAggregateFilterId(agg), getAggregateFilterExceptFor(agg.getId()));
357            fagg.subAggregation(agg.getEsAggregate());
358            ret.add(fagg);
359        }
360        return ret;
361    }
362
363    public void updateRequest(SearchSourceBuilder request) {
364        // Set limits
365        request.from(getOffset()).size(getLimit());
366        // Build query with security checks
367        request.query(makeQuery());
368        // Add sort
369        for (SortBuilder sortBuilder : getSortBuilders()) {
370            request.sort(sortBuilder);
371        }
372        // Add Aggregate
373        for (AbstractAggregationBuilder aggregate : getEsAggregates()) {
374            request.aggregation(aggregate);
375        }
376        // Add Aggregate post filter
377        QueryBuilder aggFilter = getAggregateFilter();
378        if (aggFilter != null) {
379            request.postFilter(aggFilter);
380        }
381
382        // Add highlighting
383        if (highlightFields != null && !highlightFields.isEmpty()) {
384            HighlightBuilder hb = new HighlightBuilder();
385            for (String field : highlightFields) {
386                hb.field(field);
387            }
388            hb.requireFieldMatch(false);
389            request.highlighter(hb);
390        }
391        // Fields selection
392        if (!isFetchFromElasticsearch()) {
393            request.fetchSource(getSelectFields(), null);
394        }
395
396    }
397
398    protected QueryBuilder addSecurityFilter(QueryBuilder query) {
399        NuxeoPrincipal principal = session.getPrincipal();
400        if (principal == null || principal.isAdministrator()) {
401            return query;
402        }
403        String[] principals = SecurityService.getPrincipalsToCheck(principal);
404        // we want an ACL that match principals but we discard
405        // unsupported ACE that contains negative ACE
406        QueryBuilder aclFilter = QueryBuilders.boolQuery()
407                                              .must(QueryBuilders.termsQuery(ACL_FIELD, principals))
408                                              .mustNot(QueryBuilders.termsQuery(ACL_FIELD, UNSUPPORTED_ACL));
409        return QueryBuilders.boolQuery().must(query).filter(aclFilter);
410    }
411
412    /**
413     * Add a specific repository to search. Default search is done on the session repository only.
414     *
415     * @since 6.0
416     */
417    public NxQueryBuilder addSearchRepository(String repositoryName) {
418        repositories.add(repositoryName);
419        return this;
420    }
421
422    /**
423     * Search on all available repositories.
424     *
425     * @since 6.0
426     */
427    public NxQueryBuilder searchOnAllRepositories() {
428        searchOnAllRepo = true;
429        return this;
430    }
431
432    /**
433     * Return the list of repositories to search, or an empty list to search on all available repositories;
434     *
435     * @since 6.0
436     */
437    public List<String> getSearchRepositories() {
438        if (searchOnAllRepo) {
439            return Collections.<String> emptyList();
440        }
441        return repositories;
442    }
443
444    /**
445     * @since 6.0
446     */
447    public Fetcher getFetcher(SearchResponse response, Map<String, String> repoNames) {
448        if (isFetchFromElasticsearch()) {
449            return new EsFetcher(session, response, repoNames, hitDocConsumer);
450        }
451        return new VcsFetcher(session, response, repoNames);
452    }
453
454    /**
455     * @since 7.2
456     */
457    public String[] getSelectFields() {
458        return selectFields;
459    }
460
461    /**
462     * @since 7.2
463     */
464    public Map<String, Type> getSelectFieldsAndTypes() {
465        return selectFieldsAndTypes;
466    }
467
468    /**
469     * @since 7.2
470     */
471    public boolean returnsDocuments() {
472        if (esOnly) {
473            return false;
474        }
475        return returnsDocuments;
476    }
477
478    public boolean returnsRows() {
479        if (esOnly) {
480            return false;
481        }
482        return !returnsDocuments;
483    }
484}