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 *     Tiry
018 *     bdelbosc
019 */
020package org.nuxeo.elasticsearch.query;
021
022import static org.nuxeo.elasticsearch.ElasticSearchConstants.FULLTEXT_FIELD;
023
024import java.io.IOException;
025import java.io.StringReader;
026import java.util.ArrayList;
027import java.util.Collection;
028import java.util.Iterator;
029import java.util.LinkedHashMap;
030import java.util.LinkedList;
031import java.util.List;
032import java.util.Map;
033import java.util.Set;
034
035import org.apache.commons.lang.StringUtils;
036import org.apache.commons.logging.Log;
037import org.apache.commons.logging.LogFactory;
038import org.elasticsearch.common.geo.GeoPoint;
039import org.elasticsearch.common.geo.GeoUtils;
040import org.elasticsearch.common.geo.ShapeRelation;
041import org.elasticsearch.common.xcontent.XContentBuilder;
042import org.elasticsearch.common.xcontent.XContentParser;
043import org.elasticsearch.common.xcontent.json.JsonXContent;
044import org.elasticsearch.index.query.BoolQueryBuilder;
045import org.elasticsearch.index.query.CommonTermsQueryBuilder;
046import org.elasticsearch.index.query.MatchQueryBuilder;
047import org.elasticsearch.index.query.MultiMatchQueryBuilder;
048import org.elasticsearch.index.query.QueryBuilder;
049import org.elasticsearch.index.query.QueryBuilders;
050import org.elasticsearch.index.query.QueryStringQueryBuilder;
051import org.elasticsearch.index.query.SimpleQueryStringBuilder;
052import org.nuxeo.ecm.core.NXCore;
053import org.nuxeo.ecm.core.api.CoreSession;
054import org.nuxeo.ecm.core.api.DocumentModel;
055import org.nuxeo.ecm.core.api.DocumentNotFoundException;
056import org.nuxeo.ecm.core.api.IdRef;
057import org.nuxeo.ecm.core.api.SortInfo;
058import org.nuxeo.ecm.core.query.QueryParseException;
059import org.nuxeo.ecm.core.query.sql.NXQL;
060import org.nuxeo.ecm.core.query.sql.SQLQueryParser;
061import org.nuxeo.ecm.core.query.sql.model.DefaultQueryVisitor;
062import org.nuxeo.ecm.core.query.sql.model.EsHint;
063import org.nuxeo.ecm.core.query.sql.model.Expression;
064import org.nuxeo.ecm.core.query.sql.model.FromClause;
065import org.nuxeo.ecm.core.query.sql.model.FromList;
066import org.nuxeo.ecm.core.query.sql.model.Literal;
067import org.nuxeo.ecm.core.query.sql.model.LiteralList;
068import org.nuxeo.ecm.core.query.sql.model.MultiExpression;
069import org.nuxeo.ecm.core.query.sql.model.Operand;
070import org.nuxeo.ecm.core.query.sql.model.Operator;
071import org.nuxeo.ecm.core.query.sql.model.OrderByExpr;
072import org.nuxeo.ecm.core.query.sql.model.Reference;
073import org.nuxeo.ecm.core.query.sql.model.SQLQuery;
074import org.nuxeo.ecm.core.query.sql.model.SelectClause;
075import org.nuxeo.ecm.core.schema.SchemaManager;
076import org.nuxeo.ecm.core.schema.types.Field;
077import org.nuxeo.ecm.core.schema.types.Type;
078import org.nuxeo.ecm.core.storage.sql.jdbc.NXQLQueryMaker;
079import org.nuxeo.runtime.api.Framework;
080
081/**
082 * Helper class that holds the conversion logic. Conversion is based on the existing NXQL Parser, we are just using a
083 * visitor to build the ES request.
084 */
085final public class NxqlQueryConverter {
086    private static final Log log = LogFactory.getLog(NxqlQueryConverter.class);
087
088    private static final String SELECT_ALL = "SELECT * FROM Document";
089
090    private static final String SELECT_ALL_WHERE = "SELECT * FROM Document WHERE ";
091
092    private static final String SIMPLE_QUERY_PREFIX = "es: ";
093
094    private NxqlQueryConverter() {
095    }
096
097    public static QueryBuilder toESQueryBuilder(final String nxql) {
098        return toESQueryBuilder(nxql, null);
099    }
100
101    public static QueryBuilder toESQueryBuilder(final String nxql, final CoreSession session) {
102        final LinkedList<ExpressionBuilder> builders = new LinkedList<>();
103        SQLQuery nxqlQuery = getSqlQuery(nxql);
104        if (session != null) {
105            nxqlQuery = addSecurityPolicy(session, nxqlQuery);
106        }
107        final ExpressionBuilder ret = new ExpressionBuilder(null);
108        builders.add(ret);
109        final ArrayList<String> fromList = new ArrayList<>();
110        nxqlQuery.accept(new DefaultQueryVisitor() {
111
112            private static final long serialVersionUID = 1L;
113
114            @Override
115            public void visitFromClause(FromClause node) {
116                FromList elements = node.elements;
117                SchemaManager schemaManager = Framework.getLocalService(SchemaManager.class);
118
119                for (String type : elements.values()) {
120                    if (NXQLQueryMaker.TYPE_DOCUMENT.equalsIgnoreCase(type)) {
121                        // From Document means all doc types
122                        fromList.clear();
123                        return;
124                    }
125                    Set<String> types = schemaManager.getDocumentTypeNamesExtending(type);
126                    if (types != null) {
127                        fromList.addAll(types);
128                    }
129                }
130            }
131
132            @Override
133            public void visitMultiExpression(MultiExpression node) {
134                for (Iterator<Operand> it = node.values.iterator(); it.hasNext();) {
135                    it.next().accept(this);
136                    if (it.hasNext()) {
137                        node.operator.accept(this);
138                    }
139                }
140            }
141
142            @Override
143            public void visitSelectClause(SelectClause node) {
144                // NOP
145            }
146
147            @Override
148            public void visitExpression(Expression node) {
149                Operator op = node.operator;
150                if (op == Operator.AND || op == Operator.OR || op == Operator.NOT) {
151                    builders.add(new ExpressionBuilder(op.toString()));
152                    super.visitExpression(node);
153                    ExpressionBuilder expr = builders.removeLast();
154                    if (!builders.isEmpty()) {
155                        builders.getLast().merge(expr);
156                    }
157                } else {
158                    Reference ref = node.lvalue instanceof Reference ? (Reference) node.lvalue : null;
159                    String name = ref != null ? ref.name : node.lvalue.toString();
160                    String value = null;
161                    if (node.rvalue instanceof Literal) {
162                        value = ((Literal) node.rvalue).asString();
163                    } else if (node.rvalue != null) {
164                        value = node.rvalue.toString();
165                    }
166                    Object[] values = null;
167                    if (node.rvalue instanceof LiteralList) {
168                        LiteralList items = (LiteralList) node.rvalue;
169                        values = new Object[items.size()];
170                        int i = 0;
171                        for (Literal item : items) {
172                            values[i++] = item.asString();
173                        }
174                    }
175                    // add expression to the last builder
176                    EsHint hint = (ref != null) ? ref.esHint : null;
177                    builders.getLast()
178                            .add(makeQueryFromSimpleExpression(op.toString(), name, value, values, hint, session));
179                }
180            }
181        });
182        QueryBuilder queryBuilder = ret.get();
183        if (!fromList.isEmpty()) {
184            return QueryBuilders.boolQuery().must(queryBuilder).filter(makeQueryFromSimpleExpression("IN",
185                    NXQL.ECM_PRIMARYTYPE, null, fromList.toArray(), null, null).filter);
186        }
187        return queryBuilder;
188    }
189
190    protected static SQLQuery getSqlQuery(String nxql) {
191        String query = completeQueryWithSelect(nxql);
192        SQLQuery nxqlQuery;
193        try {
194            nxqlQuery = SQLQueryParser.parse(new StringReader(query));
195        } catch (QueryParseException e) {
196            if (log.isDebugEnabled()) {
197                log.debug(e.getMessage() + " for query:\n" + query);
198            }
199            throw e;
200        }
201        return nxqlQuery;
202    }
203
204    protected static SQLQuery addSecurityPolicy(CoreSession session, SQLQuery query) {
205        Collection<SQLQuery.Transformer> transformers = NXCore.getSecurityService().getPoliciesQueryTransformers(
206                session.getRepositoryName());
207        for (SQLQuery.Transformer trans : transformers) {
208            query = trans.transform(session.getPrincipal(), query);
209        }
210        return query;
211    }
212
213    protected static String completeQueryWithSelect(String nxql) {
214        String query = (nxql == null) ? "" : nxql.trim();
215        if (query.isEmpty()) {
216            query = SELECT_ALL;
217        } else if (!query.toLowerCase().startsWith("select ")) {
218            query = SELECT_ALL_WHERE + nxql;
219        }
220        return query;
221    }
222
223    public static QueryAndFilter makeQueryFromSimpleExpression(String op, String nxqlName, Object value,
224            Object[] values, EsHint hint, CoreSession session) {
225        QueryBuilder query = null;
226        QueryBuilder filter = null;
227        String name = getFieldName(nxqlName, hint);
228        if (hint != null && hint.operator != null) {
229            if (hint.operator.startsWith("geo")) {
230                filter = makeHintFilter(name, values, hint);
231            } else {
232                query = makeHintQuery(name, value, hint);
233            }
234        } else if (nxqlName.startsWith(NXQL.ECM_FULLTEXT) && ("=".equals(op) || "!=".equals(op) || "<>".equals(op)
235                || "LIKE".equals(op) || "NOT LIKE".equals(op))) {
236            query = makeFulltextQuery(nxqlName, (String) value, hint);
237            if ("!=".equals(op) || "<>".equals(op) || "NOT LIKE".equals(op)) {
238                filter = QueryBuilders.boolQuery().mustNot(query);
239                query = null;
240            }
241        } else if (nxqlName.startsWith(NXQL.ECM_ANCESTORID)) {
242            filter = makeAncestorIdFilter((String) value, session);
243            if ("!=".equals(op) || "<>".equals(op)) {
244                filter = QueryBuilders.boolQuery().mustNot(filter);
245            }
246        } else
247            switch (op) {
248            case "=":
249                filter = QueryBuilders.termQuery(name, value);
250                break;
251            case "<>":
252            case "!=":
253                filter = QueryBuilders.boolQuery().mustNot(QueryBuilders.termQuery(name, value));
254                break;
255            case ">":
256                filter = QueryBuilders.rangeQuery(name).gt(value);
257                break;
258            case "<":
259                filter = QueryBuilders.rangeQuery(name).lt(value);
260                break;
261            case ">=":
262                filter = QueryBuilders.rangeQuery(name).gte(value);
263                break;
264            case "<=":
265                filter = QueryBuilders.rangeQuery(name).lte(value);
266                break;
267            case "BETWEEN":
268            case "NOT BETWEEN":
269                filter = QueryBuilders.rangeQuery(name).from(values[0]).to(values[1]);
270                if (op.startsWith("NOT")) {
271                    filter = QueryBuilders.boolQuery().mustNot(filter);
272                }
273                break;
274            case "IN":
275            case "NOT IN":
276                filter = QueryBuilders.termsQuery(name, values);
277                if (op.startsWith("NOT")) {
278                    filter = QueryBuilders.boolQuery().mustNot(filter);
279                }
280                break;
281            case "IS NULL":
282                filter = QueryBuilders.boolQuery().mustNot(QueryBuilders.existsQuery(name));
283                break;
284            case "IS NOT NULL":
285                filter = QueryBuilders.existsQuery(name);
286                break;
287            case "LIKE":
288            case "ILIKE":
289            case "NOT LIKE":
290            case "NOT ILIKE":
291                query = makeLikeQuery(op, name, (String) value, hint);
292                if (op.startsWith("NOT")) {
293                    filter = QueryBuilders.boolQuery().mustNot(query);
294                    query = null;
295                }
296                break;
297            case "STARTSWITH":
298                filter = makeStartsWithQuery(name, value);
299                break;
300            default:
301                throw new UnsupportedOperationException("Operator: '" + op + "' is unknown");
302            }
303        return new QueryAndFilter(query, filter);
304    }
305
306    private static QueryBuilder makeHintFilter(String name, Object[] values, EsHint hint) {
307        QueryBuilder ret;
308        switch (hint.operator) {
309        case "geo_bounding_box":
310            if (values.length != 2) {
311                throw new IllegalArgumentException(String.format(
312                        "Operator: %s requires 2 parameters: bottomLeft " + "and topRight point", hint.operator));
313            }
314            GeoPoint bottomLeft = parseGeoPointString((String) values[0]);
315            GeoPoint topRight = parseGeoPointString((String) values[1]);
316            ret = QueryBuilders.geoBoundingBoxQuery(name).bottomLeft(bottomLeft).topRight(topRight);
317            break;
318        case "geo_distance":
319            if (values.length != 2) {
320                throw new IllegalArgumentException(
321                        String.format("Operator: %s requires 2 parameters: point and " + "distance", hint.operator));
322            }
323            GeoPoint center = parseGeoPointString((String) values[0]);
324            String distance = (String) values[1];
325            ret = QueryBuilders.geoDistanceQuery(name).point(center.lat(), center.lon()).distance(distance);
326            break;
327        case "geo_distance_range":
328            if (values.length != 3) {
329                throw new IllegalArgumentException(String.format(
330                        "Operator: %s requires 3 parameters: point, " + "minimal and maximal distance", hint.operator));
331            }
332            center = parseGeoPointString((String) values[0]);
333            String from = (String) values[1];
334            String to = (String) values[2];
335            ret = QueryBuilders.geoDistanceRangeQuery(name).point(center.lat(), center.lon()).from(from).to(to);
336            break;
337        case "geo_hash_cell":
338            if (values.length != 2) {
339                throw new IllegalArgumentException(String.format(
340                        "Operator: %s requires 2 parameters: point and " + "geohash precision", hint.operator));
341            }
342            center = parseGeoPointString((String) values[0]);
343            String precision = (String) values[1];
344            ret = QueryBuilders.geoHashCellQuery(name).point(center).precision(precision);
345            break;
346        case "geo_shape":
347            if (values.length != 4) {
348                throw new IllegalArgumentException(String.format(
349                        "Operator: %s requires 4 parameters: shapeId, type, " + "index and path", hint.operator));
350            }
351            String shapeId = (String) values[0];
352            String shapeType = (String) values[1];
353            String shapeIndex = (String) values[2];
354            String shapePath = (String) values[3];
355            ret = QueryBuilders.geoShapeQuery(name, shapeId, shapeType, ShapeRelation.WITHIN)
356                               .indexedShapeIndex(shapeIndex)
357                               .indexedShapePath(shapePath);
358            break;
359        default:
360            throw new UnsupportedOperationException("Operator: '" + hint.operator + "' is unknown");
361        }
362        return ret;
363
364    }
365
366    private static GeoPoint parseGeoPointString(String value) {
367        try {
368            XContentBuilder content = JsonXContent.contentBuilder();
369            content.value(value);
370            XContentParser parser = JsonXContent.jsonXContent.createParser(content.bytes());
371            parser.nextToken();
372            return GeoUtils.parseGeoPoint(parser);
373        } catch (IOException e) {
374            throw new IllegalArgumentException("Invalid value for geopoint: " + e.getMessage());
375        }
376    }
377
378    private static QueryBuilder makeHintQuery(String name, Object value, EsHint hint) {
379        QueryBuilder ret;
380        switch (hint.operator) {
381        case "match":
382            MatchQueryBuilder matchQuery = QueryBuilders.matchQuery(name, value);
383            if (hint.analyzer != null) {
384                matchQuery.analyzer(hint.analyzer);
385            }
386            ret = matchQuery;
387            break;
388        case "match_phrase":
389            matchQuery = QueryBuilders.matchPhraseQuery(name, value);
390            if (hint.analyzer != null) {
391                matchQuery.analyzer(hint.analyzer);
392            }
393            ret = matchQuery;
394            break;
395        case "match_phrase_prefix":
396            matchQuery = QueryBuilders.matchPhrasePrefixQuery(name, value);
397            if (hint.analyzer != null) {
398                matchQuery.analyzer(hint.analyzer);
399            }
400            ret = matchQuery;
401            break;
402        case "multi_match":
403            // hint.index must be set
404            MultiMatchQueryBuilder multiMatchQuery = QueryBuilders.multiMatchQuery(value, hint.getIndex());
405            if (hint.analyzer != null) {
406                multiMatchQuery.analyzer(hint.analyzer);
407            }
408            ret = multiMatchQuery;
409            break;
410        case "regex":
411            ret = QueryBuilders.regexpQuery(name, (String) value);
412            break;
413        case "fuzzy":
414            ret = QueryBuilders.fuzzyQuery(name, (String) value);
415            break;
416        case "wildcard":
417            ret = QueryBuilders.wildcardQuery(name, (String) value);
418            break;
419        case "common":
420            CommonTermsQueryBuilder commonQuery = QueryBuilders.commonTermsQuery(name, value);
421            if (hint.analyzer != null) {
422                commonQuery.analyzer(hint.analyzer);
423            }
424            ret = commonQuery;
425            break;
426        case "query_string":
427            QueryStringQueryBuilder queryString = QueryBuilders.queryStringQuery((String) value);
428            if (hint.index != null) {
429                for (String index : hint.getIndex()) {
430                    queryString.field(index);
431                }
432            } else {
433                queryString.defaultField(name);
434            }
435            if (hint.analyzer != null) {
436                queryString.analyzer(hint.analyzer);
437            }
438            ret = queryString;
439            break;
440        case "simple_query_string":
441            SimpleQueryStringBuilder querySimpleString = QueryBuilders.simpleQueryStringQuery((String) value);
442            if (hint.index != null) {
443                for (String index : hint.getIndex()) {
444                    querySimpleString.field(index);
445                }
446            } else {
447                querySimpleString.field(name);
448            }
449            if (hint.analyzer != null) {
450                querySimpleString.analyzer(hint.analyzer);
451            }
452            ret = querySimpleString;
453            break;
454        default:
455            throw new UnsupportedOperationException("Operator: '" + hint.operator + "' is unknown");
456        }
457        return ret;
458    }
459
460    private static QueryBuilder makeStartsWithQuery(String name, Object value) {
461        QueryBuilder filter;
462        String indexName = name + ".children";
463        if ("/".equals(value)) {
464            // match all document with a path
465            filter = QueryBuilders.existsQuery(indexName);
466        } else {
467            String v = String.valueOf(value);
468            if (v.endsWith("/")) {
469                v = v.replaceAll("/$", "");
470            }
471            if (NXQL.ECM_PATH.equals(name)) {
472                // we don't want to return the parent when searching on ecm:path, see NXP-18955
473                filter = QueryBuilders.boolQuery().must(QueryBuilders.termQuery(indexName, v)).mustNot(
474                        QueryBuilders.termQuery(name, value));
475            } else {
476                filter = QueryBuilders.termQuery(indexName, v);
477            }
478        }
479        return filter;
480    }
481
482    private static QueryBuilder makeAncestorIdFilter(String value, CoreSession session) {
483        String path;
484        if (session == null) {
485            return QueryBuilders.existsQuery("ancestorid-without-session");
486        } else {
487            try {
488                DocumentModel doc = session.getDocument(new IdRef(value));
489                path = doc.getPathAsString();
490            } catch (DocumentNotFoundException e) {
491                return QueryBuilders.existsQuery("ancestorid-not-found");
492            }
493        }
494        return makeStartsWithQuery(NXQL.ECM_PATH, path);
495    }
496
497    private static QueryBuilder makeLikeQuery(String op, String name, String value, EsHint hint) {
498        String fieldName = name;
499        if (op.contains("ILIKE")) {
500            // ILIKE will work only with a correct mapping
501            value = value.toLowerCase();
502            fieldName = name + ".lowercase";
503        }
504        if (hint != null && hint.index != null) {
505            fieldName = hint.index;
506        }
507        // convert the value to a wildcard query
508        String wildcard = likeToWildcard(value);
509        // use match phrase prefix when possible
510        if (StringUtils.countMatches(wildcard, "*") == 1 && wildcard.endsWith("*") && !wildcard.contains("?")
511                && !wildcard.contains("\\")) {
512            MatchQueryBuilder query = QueryBuilders.matchPhrasePrefixQuery(fieldName, wildcard.replace("*", ""));
513            if (hint != null && hint.analyzer != null) {
514                query.analyzer(hint.analyzer);
515            }
516            return query;
517        }
518        return QueryBuilders.wildcardQuery(fieldName, wildcard);
519    }
520
521    /**
522     * Turns a NXQL LIKE pattern into a wildcard for WildcardQuery.
523     * <p>
524     * % and _ are standard wildcards, and \ escapes them.
525     *
526     * @since 7.4
527     */
528    protected static String likeToWildcard(String like) {
529        StringBuilder wildcard = new StringBuilder();
530        char[] chars = like.toCharArray();
531        boolean escape = false;
532        for (int i = 0; i < chars.length; i++) {
533            char c = chars[i];
534            boolean escapeNext = false;
535            switch (c) {
536            case '?':
537                wildcard.append("\\?");
538                break;
539            case '*': // compat, * = % in NXQL (for some backends)
540            case '%':
541                if (escape) {
542                    wildcard.append(c);
543                } else {
544                    wildcard.append("*");
545                }
546                break;
547            case '_':
548                if (escape) {
549                    wildcard.append(c);
550                } else {
551                    wildcard.append("?");
552                }
553                break;
554            case '\\':
555                if (escape) {
556                    wildcard.append("\\\\");
557                } else {
558                    escapeNext = true;
559                }
560                break;
561            default:
562                wildcard.append(c);
563                break;
564            }
565            escape = escapeNext;
566        }
567        if (escape) {
568            // invalid string terminated by escape character, ignore
569        }
570        return wildcard.toString();
571    }
572
573    private static QueryBuilder makeFulltextQuery(String nxqlName, String value, EsHint hint) {
574        String name = nxqlName.replace(NXQL.ECM_FULLTEXT, "");
575        if (name.startsWith(".")) {
576            name = name.substring(1) + ".fulltext";
577        } else {
578            // map ecm:fulltext_someindex to default
579            name = FULLTEXT_FIELD;
580        }
581        String queryString = value;
582        SimpleQueryStringBuilder.Operator defaultOperator;
583        if (queryString.startsWith(SIMPLE_QUERY_PREFIX)) {
584            // elasticsearch-specific syntax
585            queryString = queryString.substring(SIMPLE_QUERY_PREFIX.length());
586            defaultOperator = SimpleQueryStringBuilder.Operator.OR;
587        } else {
588            queryString = translateFulltextQuery(queryString);
589            defaultOperator = SimpleQueryStringBuilder.Operator.AND;
590        }
591        String analyzer = (hint != null && hint.analyzer != null) ? hint.analyzer : "fulltext";
592        SimpleQueryStringBuilder query = QueryBuilders.simpleQueryStringQuery(queryString)
593                                                      .defaultOperator(defaultOperator)
594                                                      .analyzer(analyzer);
595        if (hint != null && hint.index != null) {
596            for (String index : hint.getIndex()) {
597                query.field(index);
598            }
599        } else {
600            query.field(name);
601        }
602        return query;
603    }
604
605    private static String getFieldName(String name, EsHint hint) {
606        if (hint != null && hint.index != null) {
607            return hint.index;
608        }
609        // compat
610        if (NXQL.ECM_ISVERSION_OLD.equals(name)) {
611            name = NXQL.ECM_ISVERSION;
612        }
613        // complex field
614        name = name.replace("/*", "");
615        name = name.replace("/", ".");
616        return name;
617    }
618
619    public static List<SortInfo> getSortInfo(String nxql) {
620        final List<SortInfo> sortInfos = new ArrayList<>();
621        SQLQuery nxqlQuery = getSqlQuery(nxql);
622        nxqlQuery.accept(new DefaultQueryVisitor() {
623
624            private static final long serialVersionUID = 1L;
625
626            @Override
627            public void visitOrderByExpr(OrderByExpr node) {
628                String name = getFieldName(node.reference.name, null);
629                if (NXQL.ECM_FULLTEXT_SCORE.equals(name)) {
630                    name = "_score";
631                }
632                sortInfos.add(new SortInfo(name, !node.isDescending));
633            }
634        });
635        return sortInfos;
636    }
637
638    public static Map<String, Type> getSelectClauseFields(String nxql) {
639        final Map<String, Type> fieldsAndTypes = new LinkedHashMap<>();
640        SQLQuery nxqlQuery = getSqlQuery(nxql);
641        nxqlQuery.accept(new DefaultQueryVisitor() {
642
643            private static final long serialVersionUID = 1L;
644
645            @Override
646            public void visitSelectClause(SelectClause selectClause) {
647                SchemaManager schemaManager = Framework.getLocalService(SchemaManager.class);
648                for (int i = 0; i < selectClause.getSelectList().size(); i++) {
649                    Operand op = selectClause.get(i);
650                    if (!(op instanceof Reference)) {
651                        // ignore it
652                        continue;
653                    }
654                    String name = ((Reference) op).name;
655                    Field field = schemaManager.getField(name);
656                    fieldsAndTypes.put(name, field == null ? null : field.getType());
657                }
658            }
659        });
660        return fieldsAndTypes;
661    }
662
663    /**
664     * Translates from Nuxeo syntax to Elasticsearch simple_query_string syntax.
665     */
666    public static String translateFulltextQuery(String query) {
667        // The AND operator does not exist in NXQL it is the default operator
668        return query.replace(" OR ", " | ").replace(" or ", " | ");
669    }
670
671    /**
672     * Class to hold both a query and a filter
673     */
674    public static class QueryAndFilter {
675
676        public final QueryBuilder query;
677
678        public final QueryBuilder filter;
679
680        public QueryAndFilter(QueryBuilder query, QueryBuilder filter) {
681            this.query = query;
682            this.filter = filter;
683        }
684    }
685
686    public static class ExpressionBuilder {
687
688        public final String operator;
689
690        public QueryBuilder query;
691
692        public ExpressionBuilder(final String op) {
693            this.operator = op;
694            this.query = null;
695        }
696
697        public void add(final QueryAndFilter qf) {
698            if (qf != null) {
699                add(qf.query, qf.filter);
700            }
701        }
702
703        public void add(QueryBuilder q) {
704            add(q, null);
705        }
706
707        public void add(final QueryBuilder q, final QueryBuilder f) {
708            if (q == null && f == null) {
709                return;
710            }
711            QueryBuilder inputQuery = q;
712            if (inputQuery == null) {
713                inputQuery = QueryBuilders.constantScoreQuery(f);
714            }
715            if (operator == null) {
716                // first level expression
717                query = inputQuery;
718            } else {
719                // boolean query
720                if (query == null) {
721                    query = QueryBuilders.boolQuery();
722                }
723                BoolQueryBuilder boolQuery = (BoolQueryBuilder) query;
724                if ("AND".equals(operator)) {
725                    boolQuery.must(inputQuery);
726                } else if ("OR".equals(operator)) {
727                    boolQuery.should(inputQuery);
728                } else if ("NOT".equals(operator)) {
729                    boolQuery.mustNot(inputQuery);
730                }
731            }
732        }
733
734        public void merge(ExpressionBuilder expr) {
735            if ((expr.operator != null) && expr.operator.equals(operator) && (query == null)) {
736                query = expr.query;
737            } else {
738                add(new QueryAndFilter(expr.query, null));
739            }
740        }
741
742        public QueryBuilder get() {
743            if (query == null) {
744                return QueryBuilders.matchAllQuery();
745            }
746            return query;
747        }
748
749        @Override
750        public String toString() {
751            return query.toString();
752        }
753
754    }
755}