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 *     Florent Guillaume
018 */
019package org.nuxeo.ecm.core.storage.mongodb;
020
021import static java.lang.Boolean.FALSE;
022import static java.lang.Boolean.TRUE;
023import static org.nuxeo.ecm.core.storage.dbs.DBSDocument.KEY_ACE_GRANT;
024import static org.nuxeo.ecm.core.storage.dbs.DBSDocument.KEY_ACL;
025import static org.nuxeo.ecm.core.storage.dbs.DBSDocument.KEY_ACL_NAME;
026import static org.nuxeo.ecm.core.storage.dbs.DBSDocument.KEY_ACP;
027import static org.nuxeo.ecm.core.storage.dbs.DBSDocument.KEY_FULLTEXT_SCORE;
028import static org.nuxeo.ecm.core.storage.dbs.DBSDocument.KEY_ID;
029import static org.nuxeo.ecm.core.storage.dbs.DBSDocument.KEY_NAME;
030import static org.nuxeo.ecm.core.storage.dbs.DBSDocument.KEY_PARENT_ID;
031import static org.nuxeo.ecm.core.storage.mongodb.MongoDBRepository.MONGODB_ID;
032import static org.nuxeo.ecm.core.storage.mongodb.MongoDBRepository.MONGODB_META;
033import static org.nuxeo.ecm.core.storage.mongodb.MongoDBRepository.MONGODB_TEXT_SCORE;
034
035import java.util.ArrayList;
036import java.util.Arrays;
037import java.util.Collections;
038import java.util.Date;
039import java.util.HashMap;
040import java.util.HashSet;
041import java.util.Iterator;
042import java.util.LinkedHashMap;
043import java.util.LinkedList;
044import java.util.List;
045import java.util.Map;
046import java.util.Map.Entry;
047import java.util.Set;
048import java.util.concurrent.atomic.AtomicInteger;
049import java.util.regex.Matcher;
050import java.util.regex.Pattern;
051
052import org.apache.commons.lang.StringUtils;
053import org.apache.commons.lang.math.NumberUtils;
054import org.nuxeo.ecm.core.query.QueryParseException;
055import org.nuxeo.ecm.core.query.sql.NXQL;
056import org.nuxeo.ecm.core.query.sql.model.BooleanLiteral;
057import org.nuxeo.ecm.core.query.sql.model.DateLiteral;
058import org.nuxeo.ecm.core.query.sql.model.DoubleLiteral;
059import org.nuxeo.ecm.core.query.sql.model.Expression;
060import org.nuxeo.ecm.core.query.sql.model.Function;
061import org.nuxeo.ecm.core.query.sql.model.IntegerLiteral;
062import org.nuxeo.ecm.core.query.sql.model.Literal;
063import org.nuxeo.ecm.core.query.sql.model.LiteralList;
064import org.nuxeo.ecm.core.query.sql.model.MultiExpression;
065import org.nuxeo.ecm.core.query.sql.model.Operand;
066import org.nuxeo.ecm.core.query.sql.model.Operator;
067import org.nuxeo.ecm.core.query.sql.model.OrderByClause;
068import org.nuxeo.ecm.core.query.sql.model.OrderByExpr;
069import org.nuxeo.ecm.core.query.sql.model.Reference;
070import org.nuxeo.ecm.core.query.sql.model.SelectClause;
071import org.nuxeo.ecm.core.query.sql.model.SelectList;
072import org.nuxeo.ecm.core.query.sql.model.StringLiteral;
073import org.nuxeo.ecm.core.schema.DocumentType;
074import org.nuxeo.ecm.core.schema.SchemaManager;
075import org.nuxeo.ecm.core.schema.types.ComplexType;
076import org.nuxeo.ecm.core.schema.types.Field;
077import org.nuxeo.ecm.core.schema.types.ListType;
078import org.nuxeo.ecm.core.schema.types.Schema;
079import org.nuxeo.ecm.core.schema.types.Type;
080import org.nuxeo.ecm.core.schema.types.primitives.BooleanType;
081import org.nuxeo.ecm.core.storage.ExpressionEvaluator;
082import org.nuxeo.ecm.core.storage.ExpressionEvaluator.PathResolver;
083import org.nuxeo.ecm.core.storage.dbs.DBSDocument;
084import org.nuxeo.ecm.core.storage.dbs.DBSSession;
085import org.nuxeo.ecm.core.storage.FulltextQueryAnalyzer;
086import org.nuxeo.ecm.core.storage.FulltextQueryAnalyzer.FulltextQuery;
087import org.nuxeo.ecm.core.storage.FulltextQueryAnalyzer.Op;
088import org.nuxeo.runtime.api.Framework;
089
090import com.mongodb.BasicDBObject;
091import com.mongodb.DBObject;
092import com.mongodb.QueryOperators;
093
094/**
095 * Query builder for a MongoDB query from an {@link Expression}.
096 *
097 * @since 5.9.4
098 */
099public class MongoDBQueryBuilder {
100
101    private static final Long ZERO = Long.valueOf(0);
102
103    private static final Long ONE = Long.valueOf(1);
104
105    private static final Long MINUS_ONE = Long.valueOf(-1);
106
107    protected final AtomicInteger counter = new AtomicInteger();
108
109    protected final SchemaManager schemaManager;
110
111    protected List<String> documentTypes;
112
113    protected final Expression expression;
114
115    protected final SelectClause selectClause;
116
117    protected final OrderByClause orderByClause;
118
119    protected final PathResolver pathResolver;
120
121    public boolean hasFulltext;
122
123    public boolean sortOnFulltextScore;
124
125    protected DBObject query;
126
127    protected DBObject orderBy;
128
129    protected DBObject projection;
130
131    boolean projectionHasWildcard;
132
133    public MongoDBQueryBuilder(Expression expression, SelectClause selectClause, OrderByClause orderByClause,
134            PathResolver pathResolver) {
135        schemaManager = Framework.getLocalService(SchemaManager.class);
136        this.expression = expression;
137        this.selectClause = selectClause;
138        this.orderByClause = orderByClause;
139        this.pathResolver = pathResolver;
140    }
141
142    public void walk() {
143        query = walkExpression(expression); // computes hasFulltext
144        walkOrderBy(); // computes sortOnFulltextScore
145        walkProjection(); // needs hasFulltext and sortOnFulltextScore
146    }
147
148    public DBObject getQuery() {
149        return query;
150    }
151
152    public DBObject getOrderBy() {
153        return orderBy;
154    }
155
156    public DBObject getProjection() {
157        return projection;
158    }
159
160    public boolean hasProjectionWildcard() {
161        return projectionHasWildcard;
162    }
163
164    protected void walkOrderBy() {
165        sortOnFulltextScore = false;
166        if (orderByClause == null) {
167            orderBy = null;
168        } else {
169            orderBy = new BasicDBObject();
170            for (OrderByExpr ob : orderByClause.elements) {
171                Reference ref = ob.reference;
172                boolean desc = ob.isDescending;
173                String field = walkReference(ref).queryField;
174                if (!orderBy.containsField(field)) {
175                    Object value;
176                    if (KEY_FULLTEXT_SCORE.equals(field)) {
177                        if (!desc) {
178                            throw new QueryParseException("Cannot sort by " + NXQL.ECM_FULLTEXT_SCORE + " ascending");
179                        }
180                        sortOnFulltextScore = true;
181                        value = new BasicDBObject(MONGODB_META, MONGODB_TEXT_SCORE);
182                    } else {
183                        value = desc ? MINUS_ONE : ONE;
184                    }
185                    orderBy.put(field, value);
186                }
187            }
188            if (sortOnFulltextScore && ((BasicDBObject) orderBy).size() > 1) {
189                throw new QueryParseException("Cannot sort by " + NXQL.ECM_FULLTEXT_SCORE + " and other criteria");
190            }
191        }
192    }
193
194    protected void walkProjection() {
195        projection = new BasicDBObject();
196        projection.put(KEY_ID, ONE); // always useful
197        projection.put(KEY_NAME, ONE); // used in ORDER BY ecm:path
198        projection.put(KEY_PARENT_ID, ONE); // used in ORDER BY ecm:path
199        boolean projectionOnFulltextScore = false;
200        for (int i = 0; i < selectClause.elements.size(); i++) {
201            Operand op = selectClause.elements.get(i);
202            if (!(op instanceof Reference)) {
203                throw new QueryParseException("Projection not supported: " + op);
204            }
205            FieldInfo fieldInfo = walkReference((Reference) op);
206            projection.put(fieldInfo.projectionField, ONE);
207            if (fieldInfo.hasWildcard) {
208                projectionHasWildcard = true;
209            }
210            if (fieldInfo.projectionField.equals(KEY_FULLTEXT_SCORE)) {
211                projectionOnFulltextScore = true;
212            }
213        }
214        if (projectionOnFulltextScore || sortOnFulltextScore) {
215            if (!hasFulltext) {
216                throw new QueryParseException(NXQL.ECM_FULLTEXT_SCORE + " cannot be used without " + NXQL.ECM_FULLTEXT);
217            }
218            projection.put(KEY_FULLTEXT_SCORE, new BasicDBObject(MONGODB_META, MONGODB_TEXT_SCORE));
219        }
220    }
221
222    public DBObject walkExpression(Expression expr) {
223        Operator op = expr.operator;
224        Operand lvalue = expr.lvalue;
225        Operand rvalue = expr.rvalue;
226        String name = lvalue instanceof Reference ? ((Reference) lvalue).name : null;
227        if (op == Operator.STARTSWITH) {
228            return walkStartsWith(lvalue, rvalue);
229        } else if (NXQL.ECM_PATH.equals(name)) {
230            return walkEcmPath(op, rvalue);
231        } else if (NXQL.ECM_ANCESTORID.equals(name)) {
232            return walkAncestorId(op, rvalue);
233        } else if (name != null && name.startsWith(NXQL.ECM_FULLTEXT) && !NXQL.ECM_FULLTEXT_JOBID.equals(name)) {
234            return walkEcmFulltext(name, op, rvalue);
235        } else if (op == Operator.SUM) {
236            throw new UnsupportedOperationException("SUM");
237        } else if (op == Operator.SUB) {
238            throw new UnsupportedOperationException("SUB");
239        } else if (op == Operator.MUL) {
240            throw new UnsupportedOperationException("MUL");
241        } else if (op == Operator.DIV) {
242            throw new UnsupportedOperationException("DIV");
243        } else if (op == Operator.LT) {
244            return walkLt(lvalue, rvalue);
245        } else if (op == Operator.GT) {
246            return walkGt(lvalue, rvalue);
247        } else if (op == Operator.EQ) {
248            return walkEq(lvalue, rvalue);
249        } else if (op == Operator.NOTEQ) {
250            return walkNotEq(lvalue, rvalue);
251        } else if (op == Operator.LTEQ) {
252            return walkLtEq(lvalue, rvalue);
253        } else if (op == Operator.GTEQ) {
254            return walkGtEq(lvalue, rvalue);
255        } else if (op == Operator.AND) {
256            if (expr instanceof MultiExpression) {
257                return walkMultiExpression((MultiExpression) expr);
258            } else {
259                return walkAnd(lvalue, rvalue);
260            }
261        } else if (op == Operator.NOT) {
262            return walkNot(lvalue);
263        } else if (op == Operator.OR) {
264            return walkOr(lvalue, rvalue);
265        } else if (op == Operator.LIKE) {
266            return walkLike(lvalue, rvalue, true, false);
267        } else if (op == Operator.ILIKE) {
268            return walkLike(lvalue, rvalue, true, true);
269        } else if (op == Operator.NOTLIKE) {
270            return walkLike(lvalue, rvalue, false, false);
271        } else if (op == Operator.NOTILIKE) {
272            return walkLike(lvalue, rvalue, false, true);
273        } else if (op == Operator.IN) {
274            return walkIn(lvalue, rvalue, true);
275        } else if (op == Operator.NOTIN) {
276            return walkIn(lvalue, rvalue, false);
277        } else if (op == Operator.ISNULL) {
278            return walkIsNull(lvalue);
279        } else if (op == Operator.ISNOTNULL) {
280            return walkIsNotNull(lvalue);
281        } else if (op == Operator.BETWEEN) {
282            return walkBetween(lvalue, rvalue, true);
283        } else if (op == Operator.NOTBETWEEN) {
284            return walkBetween(lvalue, rvalue, false);
285        } else {
286            throw new QueryParseException("Unknown operator: " + op);
287        }
288    }
289
290    protected DBObject walkEcmPath(Operator op, Operand rvalue) {
291        if (op != Operator.EQ && op != Operator.NOTEQ) {
292            throw new QueryParseException(NXQL.ECM_PATH + " requires = or <> operator");
293        }
294        if (!(rvalue instanceof StringLiteral)) {
295            throw new QueryParseException(NXQL.ECM_PATH + " requires literal path as right argument");
296        }
297        String path = ((StringLiteral) rvalue).value;
298        if (path.length() > 1 && path.endsWith("/")) {
299            path = path.substring(0, path.length() - 1);
300        }
301        String id = pathResolver.getIdForPath(path);
302        if (id == null) {
303            // no such path
304            // TODO XXX do better
305            return new BasicDBObject(MONGODB_ID, "__nosuchid__");
306        }
307        if (op == Operator.EQ) {
308            return new BasicDBObject(DBSDocument.KEY_ID, id);
309        } else {
310            return new BasicDBObject(DBSDocument.KEY_ID, new BasicDBObject(QueryOperators.NE, id));
311        }
312    }
313
314    protected DBObject walkAncestorId(Operator op, Operand rvalue) {
315        if (op != Operator.EQ && op != Operator.NOTEQ) {
316            throw new QueryParseException(NXQL.ECM_ANCESTORID + " requires = or <> operator");
317        }
318        if (!(rvalue instanceof StringLiteral)) {
319            throw new QueryParseException(NXQL.ECM_ANCESTORID + " requires literal id as right argument");
320        }
321        String ancestorId = ((StringLiteral) rvalue).value;
322        if (op == Operator.EQ) {
323            return new BasicDBObject(DBSDocument.KEY_ANCESTOR_IDS, ancestorId);
324        } else {
325            return new BasicDBObject(DBSDocument.KEY_ANCESTOR_IDS, new BasicDBObject(QueryOperators.NE, ancestorId));
326        }
327    }
328
329    protected DBObject walkEcmFulltext(String name, Operator op, Operand rvalue) {
330        if (op != Operator.EQ && op != Operator.LIKE) {
331            throw new QueryParseException(NXQL.ECM_FULLTEXT + " requires = or LIKE operator");
332        }
333        if (!(rvalue instanceof StringLiteral)) {
334            throw new QueryParseException(NXQL.ECM_FULLTEXT + " requires literal string as right argument");
335        }
336        String fulltextQuery = ((StringLiteral) rvalue).value;
337        if (name.equals(NXQL.ECM_FULLTEXT)) {
338            // standard fulltext query
339            hasFulltext = true;
340            String ft = getMongoDBFulltextQuery(fulltextQuery);
341            if (ft == null) {
342                // empty query, matches nothing
343                return new BasicDBObject(MONGODB_ID, "__nosuchid__");
344            }
345            DBObject textSearch = new BasicDBObject();
346            textSearch.put(QueryOperators.SEARCH, ft);
347            // TODO language?
348            return new BasicDBObject(QueryOperators.TEXT, textSearch);
349        } else {
350            // secondary index match with explicit field
351            // do a regexp on the field
352            if (name.charAt(NXQL.ECM_FULLTEXT.length()) != '.') {
353                throw new QueryParseException(name + " has incorrect syntax" + " for a secondary fulltext index");
354            }
355            String prop = name.substring(NXQL.ECM_FULLTEXT.length() + 1);
356            String ft = fulltextQuery.replace(" ", "%");
357            rvalue = new StringLiteral(ft);
358            return walkLike(new Reference(prop), rvalue, true, true);
359        }
360    }
361
362    // public static for tests
363    public static String getMongoDBFulltextQuery(String query) {
364        FulltextQuery ft = FulltextQueryAnalyzer.analyzeFulltextQuery(query);
365        if (ft == null) {
366            return null;
367        }
368        // translate into MongoDB syntax
369        return translateFulltext(ft, false);
370    }
371
372    /**
373     * Transforms the NXQL fulltext syntax into MongoDB syntax.
374     * <p>
375     * The MongoDB fulltext query syntax is badly documented, but is actually the following:
376     * <ul>
377     * <li>a term is a word,
378     * <li>a phrase is a set of spaced-separated words enclosed in double quotes,
379     * <li>negation is done by prepending a -,
380     * <li>the query is a space-separated set of terms, negated terms, phrases, or negated phrases.
381     * <li>all the words of non-negated phrases are also added to the terms.
382     * </ul>
383     * <p>
384     * The matching algorithm is (excluding stemming and stop words):
385     * <ul>
386     * <li>filter out documents with the negative terms, the negative phrases, or missing the phrases,
387     * <li>then if any term is present in the document then it's a match.
388     * </ul>
389     */
390    protected static String translateFulltext(FulltextQuery ft, boolean and) {
391        List<String> buf = new ArrayList<>();
392        translateFulltext(ft, buf, and);
393        return StringUtils.join(buf, ' ');
394    }
395
396    protected static void translateFulltext(FulltextQuery ft, List<String> buf, boolean and) {
397        if (ft.op == Op.OR) {
398            for (FulltextQuery term : ft.terms) {
399                // don't quote words for OR
400                translateFulltext(term, buf, false);
401            }
402        } else if (ft.op == Op.AND) {
403            for (FulltextQuery term : ft.terms) {
404                // quote words for AND
405                translateFulltext(term, buf, true);
406            }
407        } else {
408            String neg;
409            if (ft.op == Op.NOTWORD) {
410                neg = "-";
411            } else { // Op.WORD
412                neg = "";
413            }
414            String word = ft.word.toLowerCase();
415            if (ft.isPhrase() || and) {
416                buf.add(neg + '"' + word + '"');
417            } else {
418                buf.add(neg + word);
419            }
420        }
421    }
422
423    public DBObject walkNot(Operand value) {
424        Object val = walkOperand(value);
425        Object not = pushDownNot(val);
426        if (!(not instanceof DBObject)) {
427            throw new QueryParseException("Cannot do NOT on: " + val);
428        }
429        return (DBObject) not;
430    }
431
432    protected Object pushDownNot(Object object) {
433        if (!(object instanceof DBObject)) {
434            throw new QueryParseException("Cannot do NOT on: " + object);
435        }
436        DBObject ob = (DBObject) object;
437        Set<String> keySet = ob.keySet();
438        if (keySet.size() != 1) {
439            throw new QueryParseException("Cannot do NOT on: " + ob);
440        }
441        String key = keySet.iterator().next();
442        Object value = ob.get(key);
443        if (!key.startsWith("$")) {
444            if (value instanceof DBObject) {
445                // push down inside dbobject
446                return new BasicDBObject(key, pushDownNot(value));
447            } else {
448                // k = v -> k != v
449                return new BasicDBObject(key, new BasicDBObject(QueryOperators.NE, value));
450            }
451        }
452        if (QueryOperators.NE.equals(key)) {
453            // NOT k != v -> k = v
454            return value;
455        }
456        if (QueryOperators.NOT.equals(key)) {
457            // NOT NOT v -> v
458            return value;
459        }
460        if (QueryOperators.AND.equals(key) || QueryOperators.OR.equals(key)) {
461            // boolean algebra
462            // NOT (v1 AND v2) -> NOT v1 OR NOT v2
463            // NOT (v1 OR v2) -> NOT v1 AND NOT v2
464            String op = QueryOperators.AND.equals(key) ? QueryOperators.OR : QueryOperators.AND;
465            List<Object> list = (List<Object>) value;
466            for (int i = 0; i < list.size(); i++) {
467                list.set(i, pushDownNot(list.get(i)));
468            }
469            return new BasicDBObject(op, list);
470        }
471        if (QueryOperators.IN.equals(key) || QueryOperators.NIN.equals(key)) {
472            // boolean algebra
473            // IN <-> NIN
474            String op = QueryOperators.IN.equals(key) ? QueryOperators.NIN : QueryOperators.IN;
475            return new BasicDBObject(op, value);
476        }
477        if (QueryOperators.LT.equals(key) || QueryOperators.GT.equals(key) || QueryOperators.LTE.equals(key)
478                || QueryOperators.GTE.equals(key)) {
479            // TODO use inverse operators?
480            return new BasicDBObject(QueryOperators.NOT, ob);
481        }
482        throw new QueryParseException("Unknown operator for NOT: " + key);
483    }
484
485    public DBObject walkIsNull(Operand value) {
486        FieldInfo fieldInfo = walkReference(value);
487        return new FieldInfoDBObject(fieldInfo, null);
488    }
489
490    public DBObject walkIsNotNull(Operand value) {
491        FieldInfo fieldInfo = walkReference(value);
492        return new FieldInfoDBObject(fieldInfo, new BasicDBObject(QueryOperators.NE, null));
493    }
494
495    public DBObject walkMultiExpression(MultiExpression expr) {
496        return walkAnd(expr.values);
497    }
498
499    public DBObject walkAnd(Operand lvalue, Operand rvalue) {
500        return walkAnd(Arrays.asList(lvalue, rvalue));
501    }
502
503    protected DBObject walkAnd(List<Operand> values) {
504        List<Object> list = walkOperandList(values);
505        // check wildcards in the operands, extract common prefixes to use $elemMatch
506        Map<String, List<FieldInfoDBObject>> propBaseKeyToDBOs = new LinkedHashMap<>();
507        Map<String, String> propBaseKeyToFieldBase = new HashMap<>();
508        for (Iterator<Object> it = list.iterator(); it.hasNext();) {
509            Object ob = it.next();
510            if (ob instanceof FieldInfoDBObject) {
511                FieldInfoDBObject fidbo = (FieldInfoDBObject) ob;
512                FieldInfo fieldInfo = fidbo.fieldInfo;
513                if (fieldInfo.hasWildcard) {
514                    if (fieldInfo.fieldSuffix.contains("*")) {
515                        // a double wildcard of the form foo/*/bar/* is not a problem if bar is an array
516                        // TODO prevent deep complex multiple wildcards
517                        // throw new QueryParseException("Cannot use two wildcards: " + fieldInfo.prop);
518                    }
519                    // generate a key unique per correlation for this element match
520                    String wildcardNumber = fieldInfo.fieldWildcard;
521                    if (wildcardNumber.isEmpty()) {
522                        // negative to not collide with regular correlated wildcards
523                        wildcardNumber = String.valueOf(-counter.incrementAndGet());
524                    }
525                    String propBaseKey = fieldInfo.fieldPrefix + "/*" + wildcardNumber;
526                    // store object for this key
527                    List<FieldInfoDBObject> dbos = propBaseKeyToDBOs.get(propBaseKey);
528                    if (dbos == null) {
529                        propBaseKeyToDBOs.put(propBaseKey, dbos = new LinkedList<>());
530                    }
531                    dbos.add(fidbo);
532                    // remember for which field base this is
533                    String fieldBase = fieldInfo.fieldPrefix.replace("/", ".");
534                    propBaseKeyToFieldBase.put(propBaseKey, fieldBase);
535                    // remove from list, will be re-added later through propBaseKeyToDBOs
536                    it.remove();
537                }
538            }
539        }
540        // generate $elemMatch items for correlated queries
541        for (Entry<String, List<FieldInfoDBObject>> es : propBaseKeyToDBOs.entrySet()) {
542            String propBaseKey = es.getKey();
543            List<FieldInfoDBObject> fidbos = es.getValue();
544            if (fidbos.size() == 1) {
545                // regular uncorrelated match
546                list.addAll(fidbos);
547            } else {
548                DBObject elemMatch = new BasicDBObject();
549                for (FieldInfoDBObject fidbo : fidbos) {
550                    // truncate field name to just the suffix
551                    FieldInfo fieldInfo = fidbo.fieldInfo;
552                    Object value = fidbo.get(fieldInfo.queryField);
553                    String fieldSuffix = fieldInfo.fieldSuffix.replace("/", ".");
554                    if (elemMatch.containsField(fieldSuffix)) {
555                        // ecm:acl/*1/principal = 'bob' AND ecm:acl/*1/principal = 'steve'
556                        // cannot match
557                        // TODO do better
558                        value = "__NOSUCHVALUE__";
559                    }
560                    elemMatch.put(fieldSuffix, value);
561                }
562                String fieldBase = propBaseKeyToFieldBase.get(propBaseKey);
563                BasicDBObject dbo = new BasicDBObject(fieldBase,
564                        new BasicDBObject(QueryOperators.ELEM_MATCH, elemMatch));
565                list.add(dbo);
566            }
567        }
568        if (list.size() == 1) {
569            return (DBObject) list.get(0);
570        } else {
571            return new BasicDBObject(QueryOperators.AND, list);
572        }
573    }
574
575    public DBObject walkOr(Operand lvalue, Operand rvalue) {
576        Object left = walkOperand(lvalue);
577        Object right = walkOperand(rvalue);
578        List<Object> list = new ArrayList<>(Arrays.asList(left, right));
579        return new BasicDBObject(QueryOperators.OR, list);
580    }
581
582    protected Object checkBoolean(FieldInfo fieldInfo, Object right) {
583        if (fieldInfo.isBoolean) {
584            // convert 0 / 1 to actual booleans
585            if (right instanceof Long) {
586                if (ZERO.equals(right)) {
587                    right = fieldInfo.isTrueOrNullBoolean ? null : FALSE;
588                } else if (ONE.equals(right)) {
589                    right = TRUE;
590                } else {
591                    throw new QueryParseException("Invalid boolean: " + right);
592                }
593            }
594        }
595        return right;
596    }
597
598    public DBObject walkEq(Operand lvalue, Operand rvalue) {
599        FieldInfo fieldInfo = walkReference(lvalue);
600        Object right = walkOperand(rvalue);
601        if (isMixinTypes(fieldInfo)) {
602            if (!(right instanceof String)) {
603                throw new QueryParseException("Invalid EQ rhs: " + rvalue);
604            }
605            return walkMixinTypes(Collections.singletonList((String) right), true);
606        }
607        right = checkBoolean(fieldInfo, right);
608        // TODO check list fields
609        return new FieldInfoDBObject(fieldInfo, right);
610    }
611
612    public DBObject walkNotEq(Operand lvalue, Operand rvalue) {
613        FieldInfo fieldInfo = walkReference(lvalue);
614        Object right = walkOperand(rvalue);
615        if (isMixinTypes(fieldInfo)) {
616            if (!(right instanceof String)) {
617                throw new QueryParseException("Invalid NE rhs: " + rvalue);
618            }
619            return walkMixinTypes(Collections.singletonList((String) right), false);
620        }
621        right = checkBoolean(fieldInfo, right);
622        // TODO check list fields
623        return new FieldInfoDBObject(fieldInfo, new BasicDBObject(QueryOperators.NE, right));
624    }
625
626    public DBObject walkLt(Operand lvalue, Operand rvalue) {
627        FieldInfo fieldInfo = walkReference(lvalue);
628        Object right = walkOperand(rvalue);
629        return new FieldInfoDBObject(fieldInfo, new BasicDBObject(QueryOperators.LT, right));
630    }
631
632    public DBObject walkGt(Operand lvalue, Operand rvalue) {
633        FieldInfo fieldInfo = walkReference(lvalue);
634        Object right = walkOperand(rvalue);
635        return new FieldInfoDBObject(fieldInfo, new BasicDBObject(QueryOperators.GT, right));
636    }
637
638    public DBObject walkLtEq(Operand lvalue, Operand rvalue) {
639        FieldInfo fieldInfo = walkReference(lvalue);
640        Object right = walkOperand(rvalue);
641        return new FieldInfoDBObject(fieldInfo, new BasicDBObject(QueryOperators.LTE, right));
642    }
643
644    public DBObject walkGtEq(Operand lvalue, Operand rvalue) {
645        FieldInfo fieldInfo = walkReference(lvalue);
646        Object right = walkOperand(rvalue);
647        return new FieldInfoDBObject(fieldInfo, new BasicDBObject(QueryOperators.GTE, right));
648    }
649
650    public DBObject walkBetween(Operand lvalue, Operand rvalue, boolean positive) {
651        LiteralList l = (LiteralList) rvalue;
652        FieldInfo fieldInfo = walkReference(lvalue);
653        Object left = walkOperand(l.get(0));
654        Object right = walkOperand(l.get(1));
655        if (positive) {
656            DBObject range = new BasicDBObject();
657            range.put(QueryOperators.GTE, left);
658            range.put(QueryOperators.LTE, right);
659            return new FieldInfoDBObject(fieldInfo, range);
660        } else {
661            DBObject a = new FieldInfoDBObject(fieldInfo, new BasicDBObject(QueryOperators.LT, left));
662            DBObject b = new FieldInfoDBObject(fieldInfo, new BasicDBObject(QueryOperators.GT, right));
663            return new BasicDBObject(QueryOperators.OR, Arrays.asList(a, b));
664        }
665    }
666
667    public DBObject walkIn(Operand lvalue, Operand rvalue, boolean positive) {
668        FieldInfo fieldInfo = walkReference(lvalue);
669        Object right = walkOperand(rvalue);
670        if (!(right instanceof List)) {
671            throw new QueryParseException("Invalid IN, right hand side must be a list: " + rvalue);
672        }
673        if (isMixinTypes(fieldInfo)) {
674            return walkMixinTypes((List<String>) right, positive);
675        }
676        // TODO check list fields
677        List<Object> list = (List<Object>) right;
678        return new FieldInfoDBObject(fieldInfo,
679                new BasicDBObject(positive ? QueryOperators.IN : QueryOperators.NIN, list));
680    }
681
682    public DBObject walkLike(Operand lvalue, Operand rvalue, boolean positive, boolean caseInsensitive) {
683        FieldInfo fieldInfo = walkReference(lvalue);
684        if (!(rvalue instanceof StringLiteral)) {
685            throw new QueryParseException("Invalid LIKE/ILIKE, right hand side must be a string: " + rvalue);
686        }
687        // TODO check list fields
688        String like = walkStringLiteral((StringLiteral) rvalue);
689        String regex = ExpressionEvaluator.likeToRegex(like);
690
691        int flags = caseInsensitive ? Pattern.CASE_INSENSITIVE : 0;
692        Pattern pattern = Pattern.compile(regex, flags);
693        Object value;
694        if (positive) {
695            value = pattern;
696        } else {
697            value = new BasicDBObject(QueryOperators.NOT, pattern);
698        }
699        return new FieldInfoDBObject(fieldInfo, value);
700    }
701
702    public Object walkOperand(Operand op) {
703        if (op instanceof Literal) {
704            return walkLiteral((Literal) op);
705        } else if (op instanceof LiteralList) {
706            return walkLiteralList((LiteralList) op);
707        } else if (op instanceof Function) {
708            return walkFunction((Function) op);
709        } else if (op instanceof Expression) {
710            return walkExpression((Expression) op);
711        } else if (op instanceof Reference) {
712            return walkReference((Reference) op);
713        } else {
714            throw new QueryParseException("Unknown operand: " + op);
715        }
716    }
717
718    public Object walkLiteral(Literal lit) {
719        if (lit instanceof BooleanLiteral) {
720            return walkBooleanLiteral((BooleanLiteral) lit);
721        } else if (lit instanceof DateLiteral) {
722            return walkDateLiteral((DateLiteral) lit);
723        } else if (lit instanceof DoubleLiteral) {
724            return walkDoubleLiteral((DoubleLiteral) lit);
725        } else if (lit instanceof IntegerLiteral) {
726            return walkIntegerLiteral((IntegerLiteral) lit);
727        } else if (lit instanceof StringLiteral) {
728            return walkStringLiteral((StringLiteral) lit);
729        } else {
730            throw new QueryParseException("Unknown literal: " + lit);
731        }
732    }
733
734    public Object walkBooleanLiteral(BooleanLiteral lit) {
735        return Boolean.valueOf(lit.value);
736    }
737
738    public Date walkDateLiteral(DateLiteral lit) {
739        return lit.value.toDate(); // TODO onlyDate
740    }
741
742    public Double walkDoubleLiteral(DoubleLiteral lit) {
743        return Double.valueOf(lit.value);
744    }
745
746    public Long walkIntegerLiteral(IntegerLiteral lit) {
747        return Long.valueOf(lit.value);
748    }
749
750    public String walkStringLiteral(StringLiteral lit) {
751        return lit.value;
752    }
753
754    public List<Object> walkLiteralList(LiteralList litList) {
755        List<Object> list = new ArrayList<Object>(litList.size());
756        for (Literal lit : litList) {
757            list.add(walkLiteral(lit));
758        }
759        return list;
760    }
761
762    protected List<Object> walkOperandList(List<Operand> values) {
763        List<Object> list = new LinkedList<>();
764        for (Operand value : values) {
765            list.add(walkOperand(value));
766        }
767        return list;
768    }
769
770    public Object walkFunction(Function func) {
771        throw new UnsupportedOperationException(func.name);
772    }
773
774    public DBObject walkStartsWith(Operand lvalue, Operand rvalue) {
775        if (!(lvalue instanceof Reference)) {
776            throw new QueryParseException("Invalid STARTSWITH query, left hand side must be a property: " + lvalue);
777        }
778        String name = ((Reference) lvalue).name;
779        if (!(rvalue instanceof StringLiteral)) {
780            throw new QueryParseException(
781                    "Invalid STARTSWITH query, right hand side must be a literal path: " + rvalue);
782        }
783        String path = ((StringLiteral) rvalue).value;
784        if (path.length() > 1 && path.endsWith("/")) {
785            path = path.substring(0, path.length() - 1);
786        }
787
788        if (NXQL.ECM_PATH.equals(name)) {
789            return walkStartsWithPath(path);
790        } else {
791            return walkStartsWithNonPath(lvalue, path);
792        }
793    }
794
795    protected DBObject walkStartsWithPath(String path) {
796        // resolve path
797        String ancestorId = pathResolver.getIdForPath(path);
798        if (ancestorId == null) {
799            // no such path
800            // TODO XXX do better
801            return new BasicDBObject(MONGODB_ID, "__nosuchid__");
802        }
803        return new BasicDBObject(DBSDocument.KEY_ANCESTOR_IDS, ancestorId);
804    }
805
806    protected DBObject walkStartsWithNonPath(Operand lvalue, String path) {
807        FieldInfo fieldInfo = walkReference(lvalue);
808        DBObject eq = new FieldInfoDBObject(fieldInfo, path);
809        // escape except alphanumeric and others not needing escaping
810        String regex = path.replaceAll("([^a-zA-Z0-9 /])", "\\\\$1");
811        Pattern pattern = Pattern.compile(regex + "/.*");
812        DBObject like = new FieldInfoDBObject(fieldInfo, pattern);
813        return new BasicDBObject(QueryOperators.OR, Arrays.asList(eq, like));
814    }
815
816    protected FieldInfo walkReference(Operand value) {
817        if (!(value instanceof Reference)) {
818            throw new QueryParseException("Invalid query, left hand side must be a property: " + value);
819        }
820        return walkReference((Reference) value);
821    }
822
823    // non-canonical index syntax, for replaceAll
824    protected final static Pattern NON_CANON_INDEX = Pattern.compile("[^/\\[\\]]+" // name
825            + "\\[(\\d+|\\*|\\*\\d+)\\]" // index in brackets
826    );
827
828    /**
829     * Canonicalizes a Nuxeo-xpath.
830     * <p>
831     * Replaces {@code a/foo[123]/b} with {@code a/123/b}
832     * <p>
833     * A star or a star followed by digits can be used instead of just the digits as well.
834     *
835     * @param xpath the xpath
836     * @return the canonicalized xpath.
837     */
838    public static String canonicalXPath(String xpath) {
839        while (xpath.length() > 0 && xpath.charAt(0) == '/') {
840            xpath = xpath.substring(1);
841        }
842        if (xpath.indexOf('[') == -1) {
843            return xpath;
844        } else {
845            return NON_CANON_INDEX.matcher(xpath).replaceAll("$1");
846        }
847    }
848
849    /** Splits foo.*.bar into foo, *, bar and foo.*1.bar into foo, *1, bar */
850    protected final static Pattern WILDCARD_SPLIT = Pattern.compile("([^*]*)\\.\\*(\\d*)\\.(.*)");
851
852    protected static class FieldInfo {
853
854        /** NXQL property. */
855        protected final String prop;
856
857        /** MongoDB field including wildcards (not used as-is). */
858        protected final String fullField;
859
860        /** MongoDB field for query. foo/0/bar -> foo.0.bar; foo / * / bar -> foo.bar */
861        protected final String queryField;
862
863        /** MongoDB field for projection. */
864        protected final String projectionField;
865
866        protected final boolean isBoolean;
867
868        /**
869         * Boolean system properties only use TRUE or NULL, not FALSE, so queries must be updated accordingly.
870         */
871        protected final boolean isTrueOrNullBoolean;
872
873        protected final boolean hasWildcard;
874
875        /** Prefix before the wildcard. */
876        protected final String fieldPrefix;
877
878        /** Wildcard part after * */
879        protected final String fieldWildcard;
880
881        /** Part after wildcard. */
882        protected final String fieldSuffix;
883
884        protected FieldInfo(String prop, String fullField, String queryField, String projectionField, boolean isBoolean,
885                boolean isTrueOrNullBoolean) {
886            this.prop = prop;
887            this.fullField = fullField;
888            this.queryField = queryField;
889            this.projectionField = projectionField;
890            this.isBoolean = isBoolean;
891            this.isTrueOrNullBoolean = isTrueOrNullBoolean;
892            Matcher m = WILDCARD_SPLIT.matcher(fullField);
893            if (m.matches()) {
894                hasWildcard = true;
895                fieldPrefix = m.group(1);
896                fieldWildcard = m.group(2);
897                fieldSuffix = m.group(3);
898            } else {
899                hasWildcard = false;
900                fieldPrefix = fieldWildcard = fieldSuffix = null;
901            }
902        }
903    }
904
905    protected static class FieldInfoDBObject extends BasicDBObject {
906
907        private static final long serialVersionUID = 1L;
908
909        protected FieldInfo fieldInfo;
910
911        public FieldInfoDBObject(FieldInfo fieldInfo, Object value) {
912            super(fieldInfo.queryField, value);
913            this.fieldInfo = fieldInfo;
914        }
915    }
916
917    /**
918     * Returns the MongoDB field for this reference.
919     */
920    public FieldInfo walkReference(Reference ref) {
921        String prop = ref.name;
922        prop = canonicalXPath(prop);
923        String[] parts = prop.split("/");
924        if (prop.startsWith(NXQL.ECM_PREFIX)) {
925            if (prop.startsWith(NXQL.ECM_ACL + "/")) {
926                if (parts.length != 3) {
927                    throw new QueryParseException("No such property: " + ref.name);
928                }
929                String wildcard = parts[1];
930                if (NumberUtils.isDigits(wildcard)) {
931                    throw new QueryParseException("Cannot use explicit index in ACLs: " + ref.name);
932                }
933                String last = parts[2];
934                String fullField;
935                String queryField;
936                String projectionField;
937                boolean isBoolean;
938                if (NXQL.ECM_ACL_NAME.equals(last)) {
939                    fullField = KEY_ACP + "." + KEY_ACL_NAME;
940                    queryField = KEY_ACP + "." + KEY_ACL_NAME;
941                    // TODO remember wildcard correlation
942                    isBoolean = false;
943                } else {
944                    String fieldLast = DBSSession.convToInternalAce(last);
945                    if (fieldLast == null) {
946                        throw new QueryParseException("No such property: " + ref.name);
947                    }
948                    isBoolean = KEY_ACE_GRANT.equals(fieldLast);
949                    fullField = KEY_ACP + "." + KEY_ACL + "." + wildcard + "." + fieldLast;
950                    queryField = KEY_ACP + "." + KEY_ACL + "." + fieldLast;
951                }
952                projectionField = queryField;
953                return new FieldInfo(prop, fullField, queryField, projectionField, isBoolean, false);
954            } else {
955                // simple field
956                String field = DBSSession.convToInternal(prop);
957                boolean isBoolean = DBSSession.isBoolean(field);
958                return new FieldInfo(prop, field, field, field, isBoolean, true);
959            }
960        } else {
961            String first = parts[0];
962            Field field = schemaManager.getField(first);
963            if (field == null) {
964                if (first.indexOf(':') > -1) {
965                    throw new QueryParseException("No such property: " + ref.name);
966                }
967                // check without prefix
968                // TODO precompute this in SchemaManagerImpl
969                for (Schema schema : schemaManager.getSchemas()) {
970                    if (!StringUtils.isBlank(schema.getNamespace().prefix)) {
971                        // schema with prefix, do not consider as candidate
972                        continue;
973                    }
974                    if (schema != null) {
975                        field = schema.getField(first);
976                        if (field != null) {
977                            break;
978                        }
979                    }
980                }
981                if (field == null) {
982                    throw new QueryParseException("No such property: " + ref.name);
983                }
984            }
985            Type type = field.getType();
986            // canonical name
987            parts[0] = field.getName().getPrefixedName();
988            // are there wildcards or list indexes?
989            List<String> queryFieldParts = new LinkedList<>(); // field for query
990            List<String> projectionFieldParts = new LinkedList<>(); // field for projection
991            boolean firstPart = true;
992            for (String part : parts) {
993                if (NumberUtils.isDigits(part)) {
994                    // explicit list index
995                    queryFieldParts.add(part);
996                    type = ((ListType) type).getFieldType();
997                } else if (!part.startsWith("*")) {
998                    // complex sub-property
999                    queryFieldParts.add(part);
1000                    projectionFieldParts.add(part);
1001                    if (!firstPart) {
1002                        // we already computed the type of the first part
1003                        field = ((ComplexType) type).getField(part);
1004                        if (field == null) {
1005                            throw new QueryParseException("No such property: " + ref.name);
1006                        }
1007                        type = field.getType();
1008                    }
1009                } else {
1010                    // wildcard
1011                    type = ((ListType) type).getFieldType();
1012                }
1013                firstPart = false;
1014            }
1015            String fullField = StringUtils.join(parts, '.');
1016            String queryField = StringUtils.join(queryFieldParts, '.');
1017            String projectionField = StringUtils.join(projectionFieldParts, '.');
1018            boolean isBoolean = type instanceof BooleanType;
1019            return new FieldInfo(prop, fullField, queryField, projectionField, isBoolean, false);
1020        }
1021    }
1022
1023    protected boolean isMixinTypes(FieldInfo fieldInfo) {
1024        return fieldInfo.queryField.equals(DBSDocument.KEY_MIXIN_TYPES);
1025    }
1026
1027    protected Set<String> getMixinDocumentTypes(String mixin) {
1028        Set<String> types = schemaManager.getDocumentTypeNamesForFacet(mixin);
1029        return types == null ? Collections.emptySet() : types;
1030    }
1031
1032    protected List<String> getDocumentTypes() {
1033        // TODO precompute in SchemaManager
1034        if (documentTypes == null) {
1035            documentTypes = new ArrayList<>();
1036            for (DocumentType docType : schemaManager.getDocumentTypes()) {
1037                documentTypes.add(docType.getName());
1038            }
1039        }
1040        return documentTypes;
1041    }
1042
1043    protected boolean isNeverPerInstanceMixin(String mixin) {
1044        return schemaManager.getNoPerDocumentQueryFacets().contains(mixin);
1045    }
1046
1047    /**
1048     * Matches the mixin types against a list of values.
1049     * <p>
1050     * Used for:
1051     * <ul>
1052     * <li>ecm:mixinTypes = 'Foo'
1053     * <li>ecm:mixinTypes != 'Foo'
1054     * <li>ecm:mixinTypes IN ('Foo', 'Bar')
1055     * <li>ecm:mixinTypes NOT IN ('Foo', 'Bar')
1056     * </ul>
1057     * <p>
1058     * ecm:mixinTypes IN ('Foo', 'Bar')
1059     *
1060     * <pre>
1061     * { "$or" : [ { "ecm:primaryType" : { "$in" : [ ... types with Foo or Bar ...]}} ,
1062     *             { "ecm:mixinTypes" : { "$in" : [ "Foo" , "Bar]}}]}
1063     * </pre>
1064     *
1065     * ecm:mixinTypes NOT IN ('Foo', 'Bar')
1066     * <p>
1067     *
1068     * <pre>
1069     * { "$and" : [ { "ecm:primaryType" : { "$in" : [ ... types without Foo nor Bar ...]}} ,
1070     *              { "ecm:mixinTypes" : { "$nin" : [ "Foo" , "Bar]}}]}
1071     * </pre>
1072     */
1073    public DBObject walkMixinTypes(List<String> mixins, boolean include) {
1074        /*
1075         * Primary types that match.
1076         */
1077        Set<String> matchPrimaryTypes;
1078        if (include) {
1079            matchPrimaryTypes = new HashSet<String>();
1080            for (String mixin : mixins) {
1081                matchPrimaryTypes.addAll(getMixinDocumentTypes(mixin));
1082            }
1083        } else {
1084            matchPrimaryTypes = new HashSet<String>(getDocumentTypes());
1085            for (String mixin : mixins) {
1086                matchPrimaryTypes.removeAll(getMixinDocumentTypes(mixin));
1087            }
1088        }
1089        /*
1090         * Instance mixins that match.
1091         */
1092        Set<String> matchMixinTypes = new HashSet<String>();
1093        for (String mixin : mixins) {
1094            if (!isNeverPerInstanceMixin(mixin)) {
1095                matchMixinTypes.add(mixin);
1096            }
1097        }
1098        /*
1099         * MongoDB query generation.
1100         */
1101        // match on primary type
1102        DBObject p = new BasicDBObject(DBSDocument.KEY_PRIMARY_TYPE,
1103                new BasicDBObject(QueryOperators.IN, matchPrimaryTypes));
1104        // match on mixin types
1105        // $in/$nin with an array matches if any/no element of the array matches
1106        String innin = include ? QueryOperators.IN : QueryOperators.NIN;
1107        DBObject m = new BasicDBObject(DBSDocument.KEY_MIXIN_TYPES, new BasicDBObject(innin, matchMixinTypes));
1108        // and/or between those
1109        String op = include ? QueryOperators.OR : QueryOperators.AND;
1110        return new BasicDBObject(op, Arrays.asList(p, m));
1111    }
1112
1113}