001/*
002 * (C) Copyright 2014-2018 Nuxeo (http://nuxeo.com/) and others.
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 *     http://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 *
016 * Contributors:
017 *     Tiry
018 *     bdelbosc
019 */
020package org.nuxeo.elasticsearch.query;
021
022import static org.nuxeo.elasticsearch.ElasticSearchConstants.FULLTEXT_FIELD;
023
024import java.io.IOException;
025import java.io.StringReader;
026import java.util.ArrayList;
027import java.util.Collection;
028import java.util.Iterator;
029import java.util.LinkedHashMap;
030import java.util.LinkedList;
031import java.util.List;
032import java.util.Map;
033import java.util.Set;
034
035import org.apache.commons.lang3.StringUtils;
036import org.apache.commons.logging.Log;
037import org.apache.commons.logging.LogFactory;
038import org.elasticsearch.common.geo.GeoPoint;
039import org.elasticsearch.common.geo.GeoUtils;
040import org.elasticsearch.common.geo.ShapeRelation;
041import org.elasticsearch.common.xcontent.NamedXContentRegistry;
042import org.elasticsearch.common.xcontent.XContentBuilder;
043import org.elasticsearch.common.xcontent.XContentParser;
044import org.elasticsearch.common.xcontent.json.JsonXContent;
045import org.elasticsearch.index.query.BoolQueryBuilder;
046import org.elasticsearch.index.query.CommonTermsQueryBuilder;
047import org.elasticsearch.index.query.MatchPhrasePrefixQueryBuilder;
048import org.elasticsearch.index.query.MatchPhraseQueryBuilder;
049import org.elasticsearch.index.query.MatchQueryBuilder;
050import org.elasticsearch.index.query.MultiMatchQueryBuilder;
051import org.elasticsearch.index.query.QueryBuilder;
052import org.elasticsearch.index.query.QueryBuilders;
053import org.elasticsearch.index.query.QueryStringQueryBuilder;
054import org.elasticsearch.index.query.SimpleQueryStringBuilder;
055import org.nuxeo.ecm.core.NXCore;
056import org.nuxeo.ecm.core.api.CoreSession;
057import org.nuxeo.ecm.core.api.DocumentModel;
058import org.nuxeo.ecm.core.api.DocumentNotFoundException;
059import org.nuxeo.ecm.core.api.IdRef;
060import org.nuxeo.ecm.core.api.LifeCycleConstants;
061import org.nuxeo.ecm.core.api.SortInfo;
062import org.nuxeo.ecm.core.query.QueryParseException;
063import org.nuxeo.ecm.core.query.sql.NXQL;
064import org.nuxeo.ecm.core.query.sql.SQLQueryParser;
065import org.nuxeo.ecm.core.query.sql.model.DefaultQueryVisitor;
066import org.nuxeo.ecm.core.query.sql.model.EsHint;
067import org.nuxeo.ecm.core.query.sql.model.Expression;
068import org.nuxeo.ecm.core.query.sql.model.FromClause;
069import org.nuxeo.ecm.core.query.sql.model.FromList;
070import org.nuxeo.ecm.core.query.sql.model.Literal;
071import org.nuxeo.ecm.core.query.sql.model.LiteralList;
072import org.nuxeo.ecm.core.query.sql.model.MultiExpression;
073import org.nuxeo.ecm.core.query.sql.model.Operand;
074import org.nuxeo.ecm.core.query.sql.model.Operator;
075import org.nuxeo.ecm.core.query.sql.model.OrderByExpr;
076import org.nuxeo.ecm.core.query.sql.model.Reference;
077import org.nuxeo.ecm.core.query.sql.model.SQLQuery;
078import org.nuxeo.ecm.core.query.sql.model.SelectClause;
079import org.nuxeo.ecm.core.schema.SchemaManager;
080import org.nuxeo.ecm.core.schema.types.Field;
081import org.nuxeo.ecm.core.schema.types.Type;
082import org.nuxeo.ecm.core.storage.sql.jdbc.NXQLQueryMaker;
083import org.nuxeo.runtime.api.Framework;
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 */
089public final 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.getService(SchemaManager.class);
122
123                for (String type : elements.values()) {
124                    if (NXQLQueryMaker.TYPE_DOCUMENT.equalsIgnoreCase(type)) {
125                        // From Document means all doc types
126                        fromList.clear();
127                        return;
128                    }
129                    Set<String> types = schemaManager.getDocumentTypeNamesExtending(type);
130                    if (types != null) {
131                        fromList.addAll(types);
132                    }
133                }
134            }
135
136            @Override
137            public void visitMultiExpression(MultiExpression node) {
138                for (Iterator<Operand> it = node.values.iterator(); it.hasNext();) {
139                    it.next().accept(this);
140                    if (it.hasNext()) {
141                        node.operator.accept(this);
142                    }
143                }
144            }
145
146            @Override
147            public void visitSelectClause(SelectClause node) {
148                // NOP
149            }
150
151            @Override
152            public void visitExpression(Expression node) {
153                Operator op = node.operator;
154                if (op == Operator.AND || op == Operator.OR || op == Operator.NOT) {
155                    builders.add(new ExpressionBuilder(op.toString()));
156                    super.visitExpression(node);
157                    ExpressionBuilder expr = builders.removeLast();
158                    if (!builders.isEmpty()) {
159                        builders.getLast().merge(expr);
160                    }
161                } else {
162                    Reference ref = node.lvalue instanceof Reference ? (Reference) node.lvalue : null;
163                    String name = ref != null ? ref.name : node.lvalue.toString();
164                    String value = null;
165                    if (node.rvalue instanceof Literal) {
166                        value = ((Literal) node.rvalue).asString();
167                    } else if (node.rvalue != null) {
168                        value = node.rvalue.toString();
169                    }
170                    Object[] values = null;
171                    if (node.rvalue instanceof LiteralList) {
172                        LiteralList items = (LiteralList) node.rvalue;
173                        values = new Object[items.size()];
174                        int i = 0;
175                        for (Literal item : items) {
176                            values[i++] = item.asString();
177                        }
178                    }
179                    // add expression to the last builder
180                    EsHint hint = (ref != null) ? ref.esHint : null;
181                    builders.getLast()
182                            .add(makeQueryFromSimpleExpression(op.toString(), name, value, values, hint, session));
183                }
184            }
185        });
186        QueryBuilder queryBuilder = ret.get();
187        if (!fromList.isEmpty()) {
188            return QueryBuilders.boolQuery().must(queryBuilder).filter(makeQueryFromSimpleExpression("IN",
189                    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        QueryBuilder 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) && ("=".equals(op) || "!=".equals(op) || "<>".equals(op)
239                || "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 = QueryBuilders.boolQuery().mustNot(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 = QueryBuilders.boolQuery().mustNot(filter);
249            }
250        } else if (nxqlName.equals(NXQL.ECM_ISTRASHED)) {
251            boolean equalsDeleted;
252            switch (op) {
253            case "=":
254                equalsDeleted = true;
255                break;
256            case "<>":
257            case "!=":
258                equalsDeleted = false;
259                break;
260            default:
261                throw new IllegalArgumentException(NXQL.ECM_ISTRASHED + " requires = or <> operator");
262            }
263            if ("0".equals(value)) {
264                equalsDeleted = !equalsDeleted;
265            } else if ("1".equals(value)) {
266                // equalsDeleted unchanged
267            } else {
268                throw new IllegalArgumentException(NXQL.ECM_ISTRASHED + " requires literal 0 or 1 as right argument");
269            }
270            filter = QueryBuilders.termQuery(NXQL.ECM_LIFECYCLESTATE, LifeCycleConstants.DELETED_STATE);
271            if (!equalsDeleted) {
272                filter = QueryBuilders.boolQuery().mustNot(filter);
273            }
274        } else
275            switch (op) {
276            case "=":
277                filter = QueryBuilders.termQuery(name, value);
278                break;
279            case "<>":
280            case "!=":
281                filter = QueryBuilders.boolQuery().mustNot(QueryBuilders.termQuery(name, value));
282                break;
283            case ">":
284                filter = QueryBuilders.rangeQuery(name).gt(value);
285                break;
286            case "<":
287                filter = QueryBuilders.rangeQuery(name).lt(value);
288                break;
289            case ">=":
290                filter = QueryBuilders.rangeQuery(name).gte(value);
291                break;
292            case "<=":
293                filter = QueryBuilders.rangeQuery(name).lte(value);
294                break;
295            case "BETWEEN":
296            case "NOT BETWEEN":
297                filter = QueryBuilders.rangeQuery(name).from(values[0]).to(values[1]);
298                if (op.startsWith("NOT")) {
299                    filter = QueryBuilders.boolQuery().mustNot(filter);
300                }
301                break;
302            case "IN":
303            case "NOT IN":
304                filter = QueryBuilders.termsQuery(name, values);
305                if (op.startsWith("NOT")) {
306                    filter = QueryBuilders.boolQuery().mustNot(filter);
307                }
308                break;
309            case "IS NULL":
310                filter = QueryBuilders.boolQuery().mustNot(QueryBuilders.existsQuery(name));
311                break;
312            case "IS NOT NULL":
313                filter = QueryBuilders.existsQuery(name);
314                break;
315            case "LIKE":
316            case "ILIKE":
317            case "NOT LIKE":
318            case "NOT ILIKE":
319                query = makeLikeQuery(op, name, (String) value, hint);
320                if (op.startsWith("NOT")) {
321                    filter = QueryBuilders.boolQuery().mustNot(query);
322                    query = null;
323                }
324                break;
325            case "STARTSWITH":
326                filter = makeStartsWithQuery(name, value);
327                break;
328            default:
329                throw new UnsupportedOperationException("Operator: '" + op + "' is unknown");
330            }
331        return new QueryAndFilter(query, filter);
332    }
333
334    private static QueryBuilder makeHintFilter(String name, Object[] values, EsHint hint) {
335        QueryBuilder ret;
336        switch (hint.operator) {
337        case "geo_bounding_box":
338            if (values.length != 2) {
339                throw new IllegalArgumentException(String.format(
340                        "Operator: %s requires 2 parameters: bottomLeft " + "and topRight point", hint.operator));
341            }
342            GeoPoint bottomLeft = parseGeoPointString((String) values[0]);
343            GeoPoint topRight = parseGeoPointString((String) values[1]);
344            ret = QueryBuilders.geoBoundingBoxQuery(name).setCornersOGC(bottomLeft, topRight);
345            break;
346        case "geo_distance":
347            if (values.length != 2) {
348                throw new IllegalArgumentException(
349                        String.format("Operator: %s requires 2 parameters: point and " + "distance", hint.operator));
350            }
351            GeoPoint center = parseGeoPointString((String) values[0]);
352            String distance = (String) values[1];
353            ret = QueryBuilders.geoDistanceQuery(name).point(center.lat(), center.lon()).distance(distance);
354            break;
355        case "geo_distance_range":
356            if (values.length != 3) {
357                throw new IllegalArgumentException(String.format(
358                        "Operator: %s requires 3 parameters: point, " + "minimal and maximal distance", hint.operator));
359            }
360            center = parseGeoPointString((String) values[0]);
361            String from = (String) values[1];
362            String to = (String) values[2];
363            ret = QueryBuilders.geoDistanceRangeQuery(name, center.lat(), center.lon()).from(from).to(to);
364            break;
365        case "geo_hash_cell":
366            if (values.length != 2) {
367                throw new IllegalArgumentException(String.format(
368                        "Operator: %s requires 2 parameters: point and " + "geohash precision", hint.operator));
369            }
370            center = parseGeoPointString((String) values[0]);
371            String precision = (String) values[1];
372            ret = QueryBuilders.geoHashCellQuery(name, center).precision(precision);
373            break;
374        case "geo_shape":
375            if (values.length != 4) {
376                throw new IllegalArgumentException(String.format(
377                        "Operator: %s requires 4 parameters: shapeId, type, " + "index and path", hint.operator));
378            }
379            String shapeId = (String) values[0];
380            String shapeType = (String) values[1];
381            String shapeIndex = (String) values[2];
382            String shapePath = (String) values[3];
383
384            ret = QueryBuilders.geoShapeQuery(name, shapeId, shapeType)
385                               .relation(ShapeRelation.WITHIN)
386                               .indexedShapeIndex(shapeIndex)
387                               .indexedShapePath(shapePath);
388            break;
389        default:
390            throw new UnsupportedOperationException("Operator: '" + hint.operator + "' is unknown");
391        }
392        return ret;
393
394    }
395
396    private static GeoPoint parseGeoPointString(String value) {
397        try {
398            XContentBuilder content = JsonXContent.contentBuilder();
399            content.value(value);
400            try (XContentParser parser = JsonXContent.jsonXContent.createParser(NamedXContentRegistry.EMPTY,
401                    content.bytes())) {
402                parser.nextToken();
403                return GeoUtils.parseGeoPoint(parser);
404            }
405        } catch (IOException e) {
406            throw new IllegalArgumentException("Invalid value for geopoint: " + e.getMessage());
407        }
408    }
409
410    private static QueryBuilder makeHintQuery(String name, Object value, EsHint hint) {
411        QueryBuilder ret;
412        switch (hint.operator) {
413        case "match":
414            MatchQueryBuilder matchQuery = QueryBuilders.matchQuery(name, value);
415            if (hint.analyzer != null) {
416                matchQuery.analyzer(hint.analyzer);
417            }
418            ret = matchQuery;
419            break;
420        case "match_phrase":
421            MatchPhraseQueryBuilder matchPhraseQuery = QueryBuilders.matchPhraseQuery(name, value);
422            if (hint.analyzer != null) {
423                matchPhraseQuery.analyzer(hint.analyzer);
424            }
425            ret = matchPhraseQuery;
426            break;
427        case "match_phrase_prefix":
428            MatchPhrasePrefixQueryBuilder matchPhrasePrefixQuery = QueryBuilders.matchPhrasePrefixQuery(name, value);
429            if (hint.analyzer != null) {
430                matchPhrasePrefixQuery.analyzer(hint.analyzer);
431            }
432            ret = matchPhrasePrefixQuery;
433            break;
434        case "multi_match":
435            // hint.index must be set
436            MultiMatchQueryBuilder multiMatchQuery = QueryBuilders.multiMatchQuery(value, hint.getIndex());
437            if (hint.analyzer != null) {
438                multiMatchQuery.analyzer(hint.analyzer);
439            }
440            ret = multiMatchQuery;
441            break;
442        case "regex":
443            ret = QueryBuilders.regexpQuery(name, (String) value);
444            break;
445        case "fuzzy":
446            ret = QueryBuilders.fuzzyQuery(name, (String) value);
447            break;
448        case "wildcard":
449            ret = QueryBuilders.wildcardQuery(name, (String) value);
450            break;
451        case "common":
452            CommonTermsQueryBuilder commonQuery = QueryBuilders.commonTermsQuery(name, value);
453            if (hint.analyzer != null) {
454                commonQuery.analyzer(hint.analyzer);
455            }
456            ret = commonQuery;
457            break;
458        case "query_string":
459            QueryStringQueryBuilder queryString = QueryBuilders.queryStringQuery((String) value);
460            if (hint.index != null) {
461                for (String index : hint.getIndex()) {
462                    queryString.field(index);
463                }
464            } else {
465                queryString.defaultField(name);
466            }
467            if (hint.analyzer != null) {
468                queryString.analyzer(hint.analyzer);
469            }
470            ret = queryString;
471            break;
472        case "simple_query_string":
473            SimpleQueryStringBuilder querySimpleString = QueryBuilders.simpleQueryStringQuery((String) value);
474            if (hint.index != null) {
475                for (String index : hint.getIndex()) {
476                    querySimpleString.field(index);
477                }
478            } else {
479                querySimpleString.field(name);
480            }
481            if (hint.analyzer != null) {
482                querySimpleString.analyzer(hint.analyzer);
483            }
484            ret = querySimpleString;
485            break;
486        default:
487            throw new UnsupportedOperationException("Operator: '" + hint.operator + "' is unknown");
488        }
489        return ret;
490    }
491
492    public static QueryBuilder makeStartsWithQuery(String name, Object value) {
493        QueryBuilder filter;
494        String indexName = name + ".children";
495        if ("/".equals(value)) {
496            // match all document with a path
497            filter = QueryBuilders.existsQuery(indexName);
498        } else {
499            String v = String.valueOf(value);
500            if (v.endsWith("/")) {
501                v = v.replaceAll("/$", "");
502            }
503            if (NXQL.ECM_PATH.equals(name)) {
504                // we don't want to return the parent when searching on ecm:path, see NXP-18955
505                filter = QueryBuilders.boolQuery().must(QueryBuilders.termQuery(indexName, v)).mustNot(
506                        QueryBuilders.termQuery(name, value));
507            } else {
508                filter = QueryBuilders.termQuery(indexName, v);
509            }
510        }
511        return filter;
512    }
513
514    private static QueryBuilder makeAncestorIdFilter(String value, CoreSession session) {
515        String path;
516        if (session == null) {
517            return QueryBuilders.existsQuery("ancestorid-without-session");
518        } else {
519            try {
520                DocumentModel doc = session.getDocument(new IdRef(value));
521                path = doc.getPathAsString();
522            } catch (DocumentNotFoundException e) {
523                return QueryBuilders.existsQuery("ancestorid-not-found");
524            }
525        }
526        return makeStartsWithQuery(NXQL.ECM_PATH, path);
527    }
528
529    private static QueryBuilder makeLikeQuery(String op, String name, String value, EsHint hint) {
530        String fieldName = name;
531        if (op.contains("ILIKE")) {
532            // ILIKE will work only with a correct mapping
533            value = value.toLowerCase();
534            fieldName = name + ".lowercase";
535        }
536        if (hint != null && hint.index != null) {
537            fieldName = hint.index;
538        }
539        // convert the value to a wildcard query
540        String wildcard = likeToWildcard(value);
541        // use match phrase prefix when possible
542        if (StringUtils.countMatches(wildcard, "*") == 1 && wildcard.endsWith("*") && !wildcard.contains("?")
543                && !wildcard.contains("\\")) {
544            MatchPhrasePrefixQueryBuilder query = QueryBuilders.matchPhrasePrefixQuery(fieldName,
545                    wildcard.replace("*", ""));
546            if (hint != null && hint.analyzer != null) {
547                query.analyzer(hint.analyzer);
548            }
549            return query;
550        }
551        return QueryBuilders.wildcardQuery(fieldName, wildcard);
552    }
553
554    /**
555     * Turns a NXQL LIKE pattern into a wildcard for WildcardQuery.
556     * <p>
557     * % and _ are standard wildcards, and \ escapes them.
558     *
559     * @since 7.4
560     */
561    protected static String likeToWildcard(String like) {
562        StringBuilder wildcard = new StringBuilder();
563        char[] chars = like.toCharArray();
564        boolean escape = false;
565        for (char c : chars) {
566            boolean escapeNext = false;
567            switch (c) {
568            case '?':
569                wildcard.append("\\?");
570                break;
571            case '*': // compat, * = % in NXQL (for some backends)
572            case '%':
573                if (escape) {
574                    wildcard.append(c);
575                } else {
576                    wildcard.append("*");
577                }
578                break;
579            case '_':
580                if (escape) {
581                    wildcard.append(c);
582                } else {
583                    wildcard.append("?");
584                }
585                break;
586            case '\\':
587                if (escape) {
588                    wildcard.append("\\\\");
589                } else {
590                    escapeNext = true;
591                }
592                break;
593            default:
594                wildcard.append(c);
595                break;
596            }
597            escape = escapeNext;
598        }
599        if (escape) {
600            // invalid string terminated by escape character, ignore
601        }
602        return wildcard.toString();
603    }
604
605    private static QueryBuilder makeFulltextQuery(String nxqlName, String value, EsHint hint) {
606        String name = nxqlName.replace(NXQL.ECM_FULLTEXT, "");
607        if (name.startsWith(".")) {
608            name = name.substring(1) + ".fulltext";
609        } else {
610            // map ecm:fulltext_someindex to default
611            name = FULLTEXT_FIELD;
612        }
613        String queryString = value;
614        org.elasticsearch.index.query.Operator defaultOperator;
615        if (queryString.startsWith(SIMPLE_QUERY_PREFIX)) {
616            // elasticsearch-specific syntax
617            queryString = queryString.substring(SIMPLE_QUERY_PREFIX.length());
618            defaultOperator = org.elasticsearch.index.query.Operator.OR;
619        } else {
620            queryString = translateFulltextQuery(queryString);
621            defaultOperator = org.elasticsearch.index.query.Operator.AND;
622        }
623        String analyzer = (hint != null && hint.analyzer != null) ? hint.analyzer : "fulltext";
624        SimpleQueryStringBuilder query = QueryBuilders.simpleQueryStringQuery(queryString)
625                                                      .defaultOperator(defaultOperator)
626                                                      .analyzer(analyzer);
627        if (hint != null && hint.index != null) {
628            for (String index : hint.getIndex()) {
629                query.field(index);
630            }
631        } else {
632            query.field(name);
633        }
634        return query;
635    }
636
637    private static String getFieldName(String name, EsHint hint) {
638        if (hint != null && hint.index != null) {
639            return hint.index;
640        }
641        // compat
642        if (NXQL.ECM_ISVERSION_OLD.equals(name)) {
643            name = NXQL.ECM_ISVERSION;
644        }
645        // complex field
646        name = name.replace("/*", "");
647        name = name.replace("/", ".");
648        return name;
649    }
650
651    public static List<SortInfo> getSortInfo(String nxql) {
652        final List<SortInfo> sortInfos = new ArrayList<>();
653        SQLQuery nxqlQuery = getSqlQuery(nxql);
654        nxqlQuery.accept(new DefaultQueryVisitor() {
655
656            private static final long serialVersionUID = 1L;
657
658            @Override
659            public void visitOrderByExpr(OrderByExpr node) {
660                String name = getFieldName(node.reference.name, null);
661                if (NXQL.ECM_FULLTEXT_SCORE.equals(name)) {
662                    name = "_score";
663                }
664                sortInfos.add(new SortInfo(name, !node.isDescending));
665            }
666        });
667        return sortInfos;
668    }
669
670    public static Map<String, Type> getSelectClauseFields(String nxql) {
671        final Map<String, Type> fieldsAndTypes = new LinkedHashMap<>();
672        SQLQuery nxqlQuery = getSqlQuery(nxql);
673        nxqlQuery.accept(new DefaultQueryVisitor() {
674
675            private static final long serialVersionUID = 1L;
676
677            @Override
678            public void visitSelectClause(SelectClause selectClause) {
679                SchemaManager schemaManager = Framework.getService(SchemaManager.class);
680                for (int i = 0; i < selectClause.getSelectList().size(); i++) {
681                    Operand op = selectClause.get(i);
682                    if (!(op instanceof Reference)) {
683                        // ignore it
684                        continue;
685                    }
686                    String name = ((Reference) op).name;
687                    Field field = schemaManager.getField(name);
688                    fieldsAndTypes.put(name, field == null ? null : field.getType());
689                }
690            }
691        });
692        return fieldsAndTypes;
693    }
694
695    /**
696     * Translates from Nuxeo syntax to Elasticsearch simple_query_string syntax.
697     */
698    public static String translateFulltextQuery(String query) {
699        // The AND operator does not exist in NXQL it is the default operator
700        return query.replace(" OR ", " | ").replace(" or ", " | ");
701    }
702
703    /**
704     * Class to hold both a query and a filter
705     */
706    public static class QueryAndFilter {
707
708        public final QueryBuilder query;
709
710        public final QueryBuilder filter;
711
712        public QueryAndFilter(QueryBuilder query, QueryBuilder filter) {
713            this.query = query;
714            this.filter = filter;
715        }
716    }
717
718    public static class ExpressionBuilder {
719
720        public final String operator;
721
722        public QueryBuilder query;
723
724        public ExpressionBuilder(final String op) {
725            this.operator = op;
726            this.query = null;
727        }
728
729        public void add(final QueryAndFilter qf) {
730            if (qf != null) {
731                add(qf.query, qf.filter);
732            }
733        }
734
735        public void add(QueryBuilder q) {
736            add(q, null);
737        }
738
739        public void add(final QueryBuilder q, final QueryBuilder f) {
740            if (q == null && f == null) {
741                return;
742            }
743            QueryBuilder inputQuery = q;
744            if (inputQuery == null) {
745                inputQuery = QueryBuilders.constantScoreQuery(f);
746            }
747            if (operator == null) {
748                // first level expression
749                query = inputQuery;
750            } else {
751                // boolean query
752                if (query == null) {
753                    query = QueryBuilders.boolQuery();
754                }
755                BoolQueryBuilder boolQuery = (BoolQueryBuilder) query;
756                if ("AND".equals(operator)) {
757                    boolQuery.must(inputQuery);
758                } else if ("OR".equals(operator)) {
759                    boolQuery.should(inputQuery);
760                } else if ("NOT".equals(operator)) {
761                    boolQuery.mustNot(inputQuery);
762                }
763            }
764        }
765
766        public void merge(ExpressionBuilder expr) {
767            if ((expr.operator != null) && expr.operator.equals(operator) && (query == null)) {
768                query = expr.query;
769            } else {
770                add(new QueryAndFilter(expr.query, null));
771            }
772        }
773
774        public QueryBuilder get() {
775            if (query == null) {
776                return QueryBuilders.matchAllQuery();
777            }
778            return query;
779        }
780
781        @Override
782        public String toString() {
783            return query.toString();
784        }
785
786    }
787}