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