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            // we don't want to return the parent
485            filter = FilterBuilders.andFilter(
486                    FilterBuilders.termFilter(indexName, v),
487                    FilterBuilders.notFilter(FilterBuilders.termFilter(name, value)));
488        }
489        return filter;
490    }
491
492    private static FilterBuilder makeAncestorIdFilter(String value, CoreSession session) {
493        String path;
494        if (session == null) {
495            return FilterBuilders.existsFilter("ancestorid-without-session");
496        } else {
497            try {
498                DocumentModel doc = session.getDocument(new IdRef(value));
499                path = doc.getPathAsString();
500            } catch (DocumentNotFoundException e) {
501                return FilterBuilders.existsFilter("ancestorid-not-found");
502            }
503        }
504        return makeStartsWithQuery(NXQL.ECM_PATH, path);
505    }
506
507    private static QueryBuilder makeLikeQuery(String op, String name, String value, EsHint hint) {
508        String fieldName = name;
509        if (op.contains("ILIKE")) {
510            // ILIKE will work only with a correct mapping
511            value = value.toLowerCase();
512            fieldName = name + ".lowercase";
513        }
514        if (hint != null && hint.index != null) {
515            fieldName = hint.index;
516        }
517        // convert the value to a wildcard query
518        String wildcard = likeToWildcard(value);
519        // use match phrase prefix when possible
520        if (StringUtils.countMatches(wildcard, "*") == 1 && wildcard.endsWith("*") && !wildcard.contains("?")
521                && !wildcard.contains("\\")) {
522            MatchQueryBuilder query = QueryBuilders.matchPhrasePrefixQuery(fieldName, wildcard.replace("*", ""));
523            if (hint != null && hint.analyzer != null) {
524                query.analyzer(hint.analyzer);
525            }
526            return query;
527        }
528        return QueryBuilders.wildcardQuery(fieldName, wildcard);
529    }
530
531    /**
532     * Turns a NXQL LIKE pattern into a wildcard for WildcardQuery.
533     * <p>
534     * % and _ are standard wildcards, and \ escapes them.
535     *
536     * @since 7.4
537     */
538    protected static String likeToWildcard(String like) {
539        StringBuilder wildcard = new StringBuilder();
540        char[] chars = like.toCharArray();
541        boolean escape = false;
542        for (int i = 0; i < chars.length; i++) {
543            char c = chars[i];
544            boolean escapeNext = false;
545            switch (c) {
546            case '?':
547                wildcard.append("\\?");
548                break;
549            case '*': // compat, * = % in NXQL (for some backends)
550            case '%':
551                if (escape) {
552                    wildcard.append(c);
553                } else {
554                    wildcard.append("*");
555                }
556                break;
557            case '_':
558                if (escape) {
559                    wildcard.append(c);
560                } else {
561                    wildcard.append("?");
562                }
563                break;
564            case '\\':
565                if (escape) {
566                    wildcard.append("\\\\");
567                } else {
568                    escapeNext = true;
569                }
570                break;
571            default:
572                wildcard.append(c);
573                break;
574            }
575            escape = escapeNext;
576        }
577        if (escape) {
578            // invalid string terminated by escape character, ignore
579        }
580        return wildcard.toString();
581    }
582
583    private static QueryBuilder makeFulltextQuery(String nxqlName, String value, EsHint hint) {
584        String name = nxqlName.replace(NXQL.ECM_FULLTEXT, "");
585        if (name.startsWith(".")) {
586            name = name.substring(1) + ".fulltext";
587        } else {
588            // map ecm:fulltext_someindex to default
589            name = FULLTEXT_FIELD;
590        }
591        String queryString = value;
592        SimpleQueryStringBuilder.Operator defaultOperator;
593        if (queryString.startsWith(SIMPLE_QUERY_PREFIX)) {
594            // elasticsearch-specific syntax
595            queryString = queryString.substring(SIMPLE_QUERY_PREFIX.length());
596            defaultOperator = SimpleQueryStringBuilder.Operator.OR;
597        } else {
598            queryString = translateFulltextQuery(queryString);
599            defaultOperator = SimpleQueryStringBuilder.Operator.AND;
600        }
601        String analyzer = (hint != null && hint.analyzer != null) ? hint.analyzer : "fulltext";
602        SimpleQueryStringBuilder query = QueryBuilders.simpleQueryStringQuery(queryString).defaultOperator
603                (defaultOperator).analyzer(
604                analyzer);
605        if (hint != null && hint.index != null) {
606            for (String index : hint.getIndex()) {
607                query.field(index);
608            }
609        } else {
610            query.field(name);
611        }
612        return query;
613    }
614
615    private static String getFieldName(String name, EsHint hint) {
616        if (hint != null && hint.index != null) {
617            return hint.index;
618        }
619        // compat
620        if (NXQL.ECM_ISVERSION_OLD.equals(name)) {
621            name = NXQL.ECM_ISVERSION;
622        }
623        // complex field
624        name = name.replace("/*", "");
625        name = name.replace("/", ".");
626        return name;
627    }
628
629    public static List<SortInfo> getSortInfo(String nxql) {
630        final List<SortInfo> sortInfos = new ArrayList<>();
631        SQLQuery nxqlQuery = getSqlQuery(nxql);
632        nxqlQuery.accept(new DefaultQueryVisitor() {
633
634            private static final long serialVersionUID = 1L;
635
636            @Override
637            public void visitOrderByExpr(OrderByExpr node) {
638                String name = getFieldName(node.reference.name, null);
639                if (NXQL.ECM_FULLTEXT_SCORE.equals(name)) {
640                    name = "_score";
641                }
642                sortInfos.add(new SortInfo(name, !node.isDescending));
643            }
644        });
645        return sortInfos;
646    }
647
648    public static Map<String, Type> getSelectClauseFields(String nxql) {
649        final Map<String, Type> fieldsAndTypes = new LinkedHashMap<>();
650        SQLQuery nxqlQuery = getSqlQuery(nxql);
651        nxqlQuery.accept(new DefaultQueryVisitor() {
652
653            private static final long serialVersionUID = 1L;
654
655            @Override
656            public void visitSelectClause(SelectClause selectClause) {
657                SchemaManager schemaManager = Framework.getLocalService(SchemaManager.class);
658                for (int i = 0; i < selectClause.getSelectList().size(); i++) {
659                    Operand op = selectClause.get(i);
660                    if (!(op instanceof Reference)) {
661                        // ignore it
662                        continue;
663                    }
664                    String name = ((Reference) op).name;
665                    Field field = schemaManager.getField(name);
666                    fieldsAndTypes.put(name, field == null ? null : field.getType());
667                }
668            }
669        });
670        return fieldsAndTypes;
671    }
672
673    /**
674     * Translates from Nuxeo syntax to Elasticsearch simple_query_string syntax.
675     */
676    public static String translateFulltextQuery(String query) {
677        // The AND operator does not exist in NXQL it is the default operator
678        return query.replace(" OR ", " | ").replace(" or ", " | ");
679    }
680
681    /**
682     * Class to hold both a query and a filter
683     */
684    public static class QueryAndFilter {
685
686        public final QueryBuilder query;
687
688        public final FilterBuilder filter;
689
690        public QueryAndFilter(QueryBuilder query, FilterBuilder filter) {
691            this.query = query;
692            this.filter = filter;
693        }
694    }
695
696    public static class ExpressionBuilder {
697
698        public final String operator;
699
700        public QueryBuilder query;
701
702        public ExpressionBuilder(final String op) {
703            this.operator = op;
704            this.query = null;
705        }
706
707        public void add(final QueryAndFilter qf) {
708            if (qf != null) {
709                add(qf.query, qf.filter);
710            }
711        }
712
713        public void add(QueryBuilder q) {
714            add(q, null);
715        }
716
717        public void add(final QueryBuilder q, final FilterBuilder f) {
718            if (q == null && f == null) {
719                return;
720            }
721            QueryBuilder inputQuery = q;
722            if (inputQuery == null) {
723                inputQuery = QueryBuilders.constantScoreQuery(f);
724            }
725            if (operator == null) {
726                // first level expression
727                query = inputQuery;
728            } else {
729                // boolean query
730                if (query == null) {
731                    query = QueryBuilders.boolQuery();
732                }
733                BoolQueryBuilder boolQuery = (BoolQueryBuilder) query;
734                if ("AND".equals(operator)) {
735                    boolQuery.must(inputQuery);
736                } else if ("OR".equals(operator)) {
737                    boolQuery.should(inputQuery);
738                } else if ("NOT".equals(operator)) {
739                    boolQuery.mustNot(inputQuery);
740                }
741            }
742        }
743
744        public void merge(ExpressionBuilder expr) {
745            if ((expr.operator != null) && expr.operator.equals(operator) && (query == null)) {
746                query = expr.query;
747            } else {
748                add(new QueryAndFilter(expr.query, null));
749            }
750        }
751
752        public QueryBuilder get() {
753            if (query == null) {
754                return QueryBuilders.matchAllQuery();
755            }
756            return query;
757        }
758
759        @Override
760        public String toString() {
761            return query.toString();
762        }
763
764    }
765}