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