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