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