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