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