001/*
002 * (C) Copyright 2014-2020 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 *     Florent Guillaume
018 */
019package org.nuxeo.ecm.core.storage.mongodb;
020
021import static org.nuxeo.ecm.core.api.trash.TrashService.Feature.TRASHED_STATE_IN_MIGRATION;
022import static org.nuxeo.ecm.core.api.trash.TrashService.Feature.TRASHED_STATE_IS_DEDICATED_PROPERTY;
023import static org.nuxeo.ecm.core.api.trash.TrashService.Feature.TRASHED_STATE_IS_DEDUCED_FROM_LIFECYCLE;
024import static org.nuxeo.ecm.core.storage.dbs.DBSDocument.FACETED_TAG;
025import static org.nuxeo.ecm.core.storage.dbs.DBSDocument.FACETED_TAG_LABEL;
026import static org.nuxeo.ecm.core.storage.dbs.DBSDocument.KEY_ACL;
027import static org.nuxeo.ecm.core.storage.dbs.DBSDocument.KEY_ACL_NAME;
028import static org.nuxeo.ecm.core.storage.dbs.DBSDocument.KEY_ACP;
029import static org.nuxeo.ecm.core.storage.dbs.DBSDocument.KEY_ANCESTOR_IDS;
030import static org.nuxeo.ecm.core.storage.dbs.DBSDocument.KEY_FULLTEXT_SCORE;
031import static org.nuxeo.ecm.core.storage.dbs.DBSDocument.KEY_ID;
032import static org.nuxeo.ecm.core.storage.dbs.DBSDocument.KEY_MIXIN_TYPES;
033import static org.nuxeo.ecm.core.storage.dbs.DBSDocument.KEY_PRIMARY_TYPE;
034import static org.nuxeo.ecm.core.storage.dbs.DBSDocument.PROP_MAJOR_VERSION;
035import static org.nuxeo.ecm.core.storage.dbs.DBSDocument.PROP_MINOR_VERSION;
036import static org.nuxeo.ecm.core.storage.dbs.DBSDocument.PROP_UID_MAJOR_VERSION;
037import static org.nuxeo.ecm.core.storage.dbs.DBSDocument.PROP_UID_MINOR_VERSION;
038import static org.nuxeo.ecm.core.storage.mongodb.MongoDBRepository.MONGODB_ID;
039import static org.nuxeo.ecm.core.storage.mongodb.MongoDBRepository.MONGODB_META;
040import static org.nuxeo.ecm.core.storage.mongodb.MongoDBRepository.MONGODB_TEXT_SCORE;
041
042import java.util.ArrayList;
043import java.util.Arrays;
044import java.util.Collections;
045import java.util.HashMap;
046import java.util.HashSet;
047import java.util.LinkedList;
048import java.util.List;
049import java.util.Map;
050import java.util.Set;
051import java.util.regex.Pattern;
052
053import org.apache.commons.lang3.StringUtils;
054import org.apache.commons.lang3.math.NumberUtils;
055import org.bson.Document;
056import org.nuxeo.ecm.core.api.LifeCycleConstants;
057import org.nuxeo.ecm.core.api.trash.TrashService;
058import org.nuxeo.ecm.core.query.QueryParseException;
059import org.nuxeo.ecm.core.query.sql.NXQL;
060import org.nuxeo.ecm.core.query.sql.model.BooleanLiteral;
061import org.nuxeo.ecm.core.query.sql.model.Expression;
062import org.nuxeo.ecm.core.query.sql.model.IntegerLiteral;
063import org.nuxeo.ecm.core.query.sql.model.Literal;
064import org.nuxeo.ecm.core.query.sql.model.Operand;
065import org.nuxeo.ecm.core.query.sql.model.Operator;
066import org.nuxeo.ecm.core.query.sql.model.OrderByClause;
067import org.nuxeo.ecm.core.query.sql.model.OrderByExpr;
068import org.nuxeo.ecm.core.query.sql.model.Reference;
069import org.nuxeo.ecm.core.query.sql.model.SelectClause;
070import org.nuxeo.ecm.core.query.sql.model.StringLiteral;
071import org.nuxeo.ecm.core.schema.DocumentType;
072import org.nuxeo.ecm.core.schema.SchemaManager;
073import org.nuxeo.ecm.core.schema.types.ComplexType;
074import org.nuxeo.ecm.core.schema.types.Field;
075import org.nuxeo.ecm.core.schema.types.ListType;
076import org.nuxeo.ecm.core.schema.types.Schema;
077import org.nuxeo.ecm.core.schema.types.Type;
078import org.nuxeo.ecm.core.schema.types.primitives.StringType;
079import org.nuxeo.ecm.core.storage.ExpressionEvaluator.PathResolver;
080import org.nuxeo.ecm.core.storage.FulltextQueryAnalyzer;
081import org.nuxeo.ecm.core.storage.FulltextQueryAnalyzer.FulltextQuery;
082import org.nuxeo.ecm.core.storage.FulltextQueryAnalyzer.Op;
083import org.nuxeo.ecm.core.storage.dbs.DBSSession;
084import org.nuxeo.runtime.api.Framework;
085import org.nuxeo.runtime.mongodb.MongoDBOperators;
086
087/**
088 * Query builder for a MongoDB query of the repository from an {@link Expression}.
089 *
090 * @since 5.9.4
091 */
092
093public class MongoDBRepositoryQueryBuilder extends MongoDBAbstractQueryBuilder {
094
095    protected final SchemaManager schemaManager;
096
097    protected final String idKey;
098
099    protected List<String> documentTypes;
100
101    protected final SelectClause selectClause;
102
103    protected final OrderByClause orderByClause;
104
105    protected final PathResolver pathResolver;
106
107    public boolean hasFulltext;
108
109    public boolean sortOnFulltextScore;
110
111    protected Document orderBy;
112
113    protected Document projection;
114
115    protected Map<String, String> propertyKeys;
116
117    boolean projectionHasWildcard;
118
119    private boolean fulltextSearchDisabled;
120
121    public MongoDBRepositoryQueryBuilder(MongoDBRepository repository, Expression expression, SelectClause selectClause,
122            OrderByClause orderByClause, PathResolver pathResolver, boolean fulltextSearchDisabled) {
123        super(repository.getConverter(), expression);
124        schemaManager = Framework.getService(SchemaManager.class);
125        idKey = repository.getIdKey();
126        this.selectClause = selectClause;
127        this.orderByClause = orderByClause;
128        this.pathResolver = pathResolver;
129        this.fulltextSearchDisabled = fulltextSearchDisabled;
130        this.propertyKeys = new HashMap<>();
131    }
132
133    @Override
134    public void walk() {
135        super.walk(); // computes hasFulltext
136        walkOrderBy(); // computes sortOnFulltextScore
137        walkProjection(); // needs hasFulltext and sortOnFulltextScore
138    }
139
140    public Document getOrderBy() {
141        return orderBy;
142    }
143
144    public Document getProjection() {
145        return projection;
146    }
147
148    public boolean hasProjectionWildcard() {
149        return projectionHasWildcard;
150    }
151
152    protected void walkOrderBy() {
153        sortOnFulltextScore = false;
154        if (orderByClause == null) {
155            orderBy = null;
156        } else {
157            orderBy = new Document();
158            for (OrderByExpr ob : orderByClause.elements) {
159                Reference ref = ob.reference;
160                boolean desc = ob.isDescending;
161                String field = walkReference(ref).queryField;
162                if (!orderBy.containsKey(field)) {
163                    Object value;
164                    if (KEY_FULLTEXT_SCORE.equals(field)) {
165                        if (!desc) {
166                            throw new QueryParseException("Cannot sort by " + NXQL.ECM_FULLTEXT_SCORE + " ascending");
167                        }
168                        sortOnFulltextScore = true;
169                        value = new Document(MONGODB_META, MONGODB_TEXT_SCORE);
170                    } else {
171                        value = desc ? MINUS_ONE : ONE;
172                    }
173                    orderBy.put(field, value);
174                }
175            }
176            if (sortOnFulltextScore && orderBy.size() > 1) {
177                throw new QueryParseException("Cannot sort by " + NXQL.ECM_FULLTEXT_SCORE + " and other criteria");
178            }
179        }
180    }
181
182    protected void walkProjection() {
183        projection = new Document();
184        boolean projectionOnFulltextScore = false;
185        for (Operand op : selectClause.getSelectList().values()) {
186            if (!(op instanceof Reference)) {
187                throw new QueryParseException("Projection not supported: " + op);
188            }
189            FieldInfo fieldInfo = walkReference((Reference) op);
190            String propertyField = fieldInfo.prop;
191            if (!propertyField.equals(NXQL.ECM_UUID) //
192                    && !propertyField.equals(fieldInfo.projectionField) //
193                    && !propertyField.contains("/")) {
194                propertyKeys.put(fieldInfo.projectionField, propertyField);
195            }
196            projection.put(fieldInfo.projectionField, ONE);
197            if (propertyField.contains("*")) {
198                projectionHasWildcard = true;
199            }
200            if (fieldInfo.projectionField.equals(KEY_FULLTEXT_SCORE)) {
201                projectionOnFulltextScore = true;
202            }
203        }
204        if (projectionOnFulltextScore || sortOnFulltextScore) {
205            if (!hasFulltext) {
206                throw new QueryParseException(NXQL.ECM_FULLTEXT_SCORE + " cannot be used without " + NXQL.ECM_FULLTEXT);
207            }
208            projection.put(KEY_FULLTEXT_SCORE, new Document(MONGODB_META, MONGODB_TEXT_SCORE));
209        }
210    }
211
212    @Override
213    public Document walkExpression(Expression expr) {
214        Operator op = expr.operator;
215        Operand lvalue = expr.lvalue;
216        Operand rvalue = expr.rvalue;
217        Reference ref = lvalue instanceof Reference ? (Reference) lvalue : null;
218        String name = ref != null ? ref.name : null;
219        if (op == Operator.STARTSWITH) {
220            return walkStartsWith(lvalue, rvalue);
221        } else if (NXQL.ECM_PATH.equals(name)) {
222            return walkEcmPath(op, rvalue);
223        } else if (NXQL.ECM_ANCESTORID.equals(name)) {
224            return walkAncestorId(op, rvalue);
225        } else if (NXQL.ECM_ISTRASHED.equals(name)) {
226            return walkIsTrashed(op, rvalue);
227        } else if (name != null && name.startsWith(NXQL.ECM_FULLTEXT) && !NXQL.ECM_FULLTEXT_JOBID.equals(name)) {
228            return walkEcmFulltext(name, op, rvalue);
229        } else {
230            return super.walkExpression(expr);
231        }
232    }
233
234    protected Document walkEcmPath(Operator op, Operand rvalue) {
235        if (op != Operator.EQ && op != Operator.NOTEQ) {
236            throw new QueryParseException(NXQL.ECM_PATH + " requires = or <> operator");
237        }
238        if (!(rvalue instanceof StringLiteral)) {
239            throw new QueryParseException(NXQL.ECM_PATH + " requires literal path as right argument");
240        }
241        String path = ((StringLiteral) rvalue).value;
242        if (path.length() > 1 && path.endsWith("/")) {
243            path = path.substring(0, path.length() - 1);
244        }
245        String id = pathResolver.getIdForPath(path);
246        if (id == null) {
247            // no such path
248            // TODO XXX do better
249            return new Document(MONGODB_ID, "__nosuchid__");
250        }
251        Object bsonId = converter.serializableToBson(KEY_ID, id);
252        if (op == Operator.EQ) {
253            return new Document(idKey, bsonId);
254        } else {
255            return new Document(idKey, new Document(MongoDBOperators.NE, bsonId));
256        }
257    }
258
259    protected Document walkAncestorId(Operator op, Operand rvalue) {
260        if (op != Operator.EQ && op != Operator.NOTEQ) {
261            throw new QueryParseException(NXQL.ECM_ANCESTORID + " requires = or <> operator");
262        }
263        if (!(rvalue instanceof StringLiteral)) {
264            throw new QueryParseException(NXQL.ECM_ANCESTORID + " requires literal id as right argument");
265        }
266        String ancestorId = ((StringLiteral) rvalue).value;
267        Object bsonAncestorId = converter.serializableToBson(KEY_ANCESTOR_IDS, ancestorId);
268        if (op == Operator.EQ) {
269            return new Document(KEY_ANCESTOR_IDS, bsonAncestorId);
270        } else {
271            return new Document(KEY_ANCESTOR_IDS, new Document(MongoDBOperators.NE, bsonAncestorId));
272        }
273    }
274
275    protected Document walkEcmFulltext(String name, Operator op, Operand rvalue) {
276        if (op != Operator.EQ && op != Operator.LIKE) {
277            throw new QueryParseException(NXQL.ECM_FULLTEXT + " requires = or LIKE operator");
278        }
279        if (!(rvalue instanceof StringLiteral)) {
280            throw new QueryParseException(NXQL.ECM_FULLTEXT + " requires literal string as right argument");
281        }
282        if (fulltextSearchDisabled) {
283            throw new QueryParseException("Fulltext search disabled by configuration");
284        }
285        String fulltextQuery = ((StringLiteral) rvalue).value;
286        if (name.equals(NXQL.ECM_FULLTEXT)) {
287            // standard fulltext query
288            hasFulltext = true;
289            String ft = getMongoDBFulltextQuery(fulltextQuery);
290            if (ft == null) {
291                // empty query, matches nothing
292                return new Document(MONGODB_ID, "__nosuchid__");
293            }
294            Document textSearch = new Document();
295            textSearch.put(MongoDBOperators.SEARCH, ft);
296            // TODO language?
297            return new Document(MongoDBOperators.TEXT, textSearch);
298        } else {
299            // secondary index match with explicit field
300            // do a regexp on the field
301            if (name.charAt(NXQL.ECM_FULLTEXT.length()) != '.') {
302                throw new QueryParseException(name + " has incorrect syntax" + " for a secondary fulltext index");
303            }
304            String prop = name.substring(NXQL.ECM_FULLTEXT.length() + 1);
305            String ft = fulltextQuery.replace(" ", "%");
306            rvalue = new StringLiteral(ft);
307            return walkLike(new Reference(prop), rvalue, true, true);
308        }
309    }
310
311    protected Document walkIsTrashed(Operator op, Operand rvalue) {
312        if (op != Operator.EQ && op != Operator.NOTEQ) {
313            throw new QueryParseException(NXQL.ECM_ISTRASHED + " requires = or <> operator");
314        }
315        TrashService trashService = Framework.getService(TrashService.class);
316        if (trashService.hasFeature(TRASHED_STATE_IS_DEDUCED_FROM_LIFECYCLE)) {
317            return walkIsTrashed(new Reference(NXQL.ECM_LIFECYCLESTATE), op, rvalue,
318                    new StringLiteral(LifeCycleConstants.DELETED_STATE));
319        } else if (trashService.hasFeature(TRASHED_STATE_IN_MIGRATION)) {
320            Document lifeCycleTrashed = walkIsTrashed(new Reference(NXQL.ECM_LIFECYCLESTATE), op, rvalue,
321                    new StringLiteral(LifeCycleConstants.DELETED_STATE));
322            Document propertyTrashed = walkIsTrashed(new Reference(NXQL.ECM_ISTRASHED), op, rvalue,
323                    new BooleanLiteral(true));
324            return new Document(MongoDBOperators.OR, new ArrayList<>(Arrays.asList(lifeCycleTrashed, propertyTrashed)));
325        } else if (trashService.hasFeature(TRASHED_STATE_IS_DEDICATED_PROPERTY)) {
326            return walkIsTrashed(new Reference(NXQL.ECM_ISTRASHED), op, rvalue, new BooleanLiteral(true));
327        } else {
328            throw new UnsupportedOperationException("TrashService is in an unknown state");
329        }
330    }
331
332    protected Document walkIsTrashed(Reference ref, Operator op, Operand initialRvalue, Literal deletedRvalue) {
333        long v;
334        if (!(initialRvalue instanceof IntegerLiteral)
335                || ((v = ((IntegerLiteral) initialRvalue).value) != 0 && v != 1)) {
336            throw new QueryParseException(NXQL.ECM_ISTRASHED + " requires literal 0 or 1 as right argument");
337        }
338        boolean equalsDeleted = op == Operator.EQ ^ v == 0;
339        if (equalsDeleted) {
340            return walkEq(ref, deletedRvalue);
341        } else {
342            return walkNotEq(ref, deletedRvalue);
343        }
344    }
345
346    // public static for tests
347    public static String getMongoDBFulltextQuery(String query) {
348        FulltextQuery ft = FulltextQueryAnalyzer.analyzeFulltextQuery(query);
349        if (ft == null) {
350            return null;
351        }
352        // translate into MongoDB syntax
353        return translateFulltext(ft, false);
354    }
355
356    /**
357     * Transforms the NXQL fulltext syntax into MongoDB syntax.
358     * <p>
359     * The MongoDB fulltext query syntax is badly documented, but is actually the following:
360     * <ul>
361     * <li>a term is a word,
362     * <li>a phrase is a set of spaced-separated words enclosed in double quotes,
363     * <li>negation is done by prepending a -,
364     * <li>the query is a space-separated set of terms, negated terms, phrases, or negated phrases.
365     * <li>all the words of non-negated phrases are also added to the terms.
366     * </ul>
367     * <p>
368     * The matching algorithm is (excluding stemming and stop words):
369     * <ul>
370     * <li>filter out documents with the negative terms, the negative phrases, or missing the phrases,
371     * <li>then if any term is present in the document then it's a match.
372     * </ul>
373     */
374    protected static String translateFulltext(FulltextQuery ft, boolean and) {
375        List<String> buf = new ArrayList<>();
376        translateFulltext(ft, buf, and);
377        return StringUtils.join(buf, ' ');
378    }
379
380    protected static void translateFulltext(FulltextQuery ft, List<String> buf, boolean and) {
381        if (ft.op == Op.OR) {
382            for (FulltextQuery term : ft.terms) {
383                // don't quote words for OR
384                translateFulltext(term, buf, false);
385            }
386        } else if (ft.op == Op.AND) {
387            for (FulltextQuery term : ft.terms) {
388                // quote words for AND
389                translateFulltext(term, buf, true);
390            }
391        } else {
392            String neg;
393            if (ft.op == Op.NOTWORD) {
394                neg = "-";
395            } else { // Op.WORD
396                neg = "";
397            }
398            String word = ft.word.toLowerCase();
399            if (ft.isPhrase() || and) {
400                buf.add(neg + '"' + word + '"');
401            } else {
402                buf.add(neg + word);
403            }
404        }
405    }
406
407    @Override
408    public Document walkEq(Operand lvalue, Operand rvalue) {
409        FieldInfo fieldInfo = walkReference(lvalue);
410        if (isMixinTypes(fieldInfo)) {
411            Object right = walkOperand(fieldInfo, rvalue);
412            if (!(right instanceof String)) {
413                throw new QueryParseException("Invalid EQ rhs: " + rvalue);
414            }
415            return walkMixinTypes(Collections.singletonList((String) right), true);
416        }
417        return super.walkEq(fieldInfo, rvalue);
418    }
419
420    @Override
421    public Document walkNotEq(Operand lvalue, Operand rvalue) {
422        FieldInfo fieldInfo = walkReference(lvalue);
423        if (isMixinTypes(fieldInfo)) {
424            Object right = walkOperand(fieldInfo, rvalue);
425            if (!(right instanceof String)) {
426                throw new QueryParseException("Invalid NE rhs: " + rvalue);
427            }
428            return walkMixinTypes(Collections.singletonList((String) right), false);
429        }
430        return super.walkNotEq(fieldInfo, rvalue);
431    }
432
433    @Override
434    public Document walkIn(Operand lvalue, Operand rvalue, boolean positive) {
435        FieldInfo fieldInfo = walkReference(lvalue);
436        if (isMixinTypes(fieldInfo)) {
437            Object right = walkOperand(fieldInfo, rvalue);
438            if (!(right instanceof List)) {
439                throw new QueryParseException("Invalid IN, right hand side must be a list: " + rvalue);
440            }
441            return walkMixinTypes((List<String>) right, positive);
442        }
443        return super.walkIn(fieldInfo, rvalue, positive);
444    }
445
446    public Document walkStartsWith(Operand lvalue, Operand rvalue) {
447        if (!(lvalue instanceof Reference)) {
448            throw new QueryParseException("Invalid STARTSWITH query, left hand side must be a property: " + lvalue);
449        }
450        String name = ((Reference) lvalue).name;
451        if (!(rvalue instanceof StringLiteral)) {
452            throw new QueryParseException(
453                    "Invalid STARTSWITH query, right hand side must be a literal path: " + rvalue);
454        }
455        String path = ((StringLiteral) rvalue).value;
456        if (path.length() > 1 && path.endsWith("/")) {
457            path = path.substring(0, path.length() - 1);
458        }
459
460        if (NXQL.ECM_PATH.equals(name)) {
461            return walkStartsWithPath(path);
462        } else {
463            return walkStartsWithNonPath(lvalue, path);
464        }
465    }
466
467    protected Document walkStartsWithPath(String path) {
468        // resolve path
469        String ancestorId = pathResolver.getIdForPath(path);
470        if (ancestorId == null) {
471            // no such path
472            // TODO XXX do better
473            return new Document(MONGODB_ID, "__nosuchid__");
474        }
475        Object bsonAncestorId = converter.serializableToBson(KEY_ANCESTOR_IDS, ancestorId);
476        return new Document(KEY_ANCESTOR_IDS, bsonAncestorId);
477    }
478
479    protected Document walkStartsWithNonPath(Operand lvalue, String path) {
480        FieldInfo fieldInfo = walkReference(lvalue);
481        Document eq = newDocumentWithField(fieldInfo, path);
482        // escape except alphanumeric and others not needing escaping
483        String regex = path.replaceAll("([^a-zA-Z0-9 /])", "\\\\$1");
484        Pattern pattern = Pattern.compile(regex + "/.*");
485        Document like = newDocumentWithField(fieldInfo, pattern);
486        return new Document(MongoDBOperators.OR, Arrays.asList(eq, like));
487    }
488
489    // non-canonical index syntax, for replaceAll
490    protected final static Pattern NON_CANON_INDEX = Pattern.compile("[^/\\[\\]]+" // name
491            + "\\[(\\d+|\\*|\\*\\d+)\\]" // index in brackets
492    );
493
494    /**
495     * Canonicalizes a Nuxeo-xpath.
496     * <p>
497     * Replaces {@code a/foo[123]/b} with {@code a/123/b}
498     * <p>
499     * A star or a star followed by digits can be used instead of just the digits as well.
500     *
501     * @param xpath the xpath
502     * @return the canonicalized xpath.
503     */
504    public static String canonicalXPath(String xpath) {
505        while (xpath.length() > 0 && xpath.charAt(0) == '/') {
506            xpath = xpath.substring(1);
507        }
508        if (xpath.indexOf('[') == -1) {
509            return xpath;
510        } else {
511            return NON_CANON_INDEX.matcher(xpath).replaceAll("$1");
512        }
513    }
514
515    @Override
516    protected FieldInfo walkReference(String name) {
517        String prop = canonicalXPath(name);
518        String[] parts = prop.split("/");
519        if (prop.startsWith(NXQL.ECM_PREFIX)) {
520            if (prop.startsWith(NXQL.ECM_ACL + "/")) {
521                return parseACP(prop, parts);
522            }
523            if (prop.startsWith(NXQL.ECM_TAG)) {
524                String queryField = FACETED_TAG + "." + FACETED_TAG_LABEL;
525                queryField = stripElemMatchPrefix(queryField);
526                return new FieldInfo(prop, prop, queryField, queryField, StringType.INSTANCE);
527            }
528            // simple field
529            String field = DBSSession.convToInternal(prop);
530            Type type = DBSSession.getType(field);
531            String queryField = converter.keyToBson(field);
532            queryField = stripElemMatchPrefix(queryField);
533            return new FieldInfo(prop, field, queryField, queryField, type);
534        } else {
535            String first = parts[0];
536            Field field = schemaManager.getField(first);
537            if (field == null) {
538                if (first.indexOf(':') > -1) {
539                    throw new QueryParseException("No such property: " + name);
540                }
541                // check without prefix
542                // TODO precompute this in SchemaManagerImpl
543                for (Schema schema : schemaManager.getSchemas()) {
544                    if (!StringUtils.isBlank(schema.getNamespace().prefix)) {
545                        // schema with prefix, do not consider as candidate
546                        continue;
547                    }
548                    field = schema.getField(first);
549                    if (field != null) {
550                        break;
551                    }
552                }
553                if (field == null) {
554                    throw new QueryParseException("No such property: " + name);
555                }
556            }
557            Type type = field.getType();
558            if (PROP_UID_MAJOR_VERSION.equals(prop) || PROP_UID_MINOR_VERSION.equals(prop)
559                    || PROP_MAJOR_VERSION.equals(prop) || PROP_MINOR_VERSION.equals(prop)) {
560                String fieldName = DBSSession.convToInternal(prop);
561                return new FieldInfo(prop, fieldName, fieldName, fieldName, type);
562            }
563
564            // canonical name
565            parts[0] = field.getName().getPrefixedName();
566            // are there wildcards or list indexes?
567            List<String> queryFieldParts = new LinkedList<>(); // field for query
568            List<String> projectionFieldParts = new LinkedList<>(); // field for projection
569            boolean firstPart = true;
570            for (String part : parts) {
571                if (NumberUtils.isDigits(part)) {
572                    // explicit list index
573                    queryFieldParts.add(part);
574                    type = ((ListType) type).getFieldType();
575                } else if (!part.startsWith("*")) {
576                    // complex sub-property
577                    queryFieldParts.add(part);
578                    projectionFieldParts.add(part);
579                    if (!firstPart) {
580                        // we already computed the type of the first part
581                        field = ((ComplexType) type).getField(part);
582                        if (field == null) {
583                            throw new QueryParseException("No such property: " + name);
584                        }
585                        type = field.getType();
586                    }
587                } else {
588                    // wildcard
589                    type = ((ListType) type).getFieldType();
590                }
591                firstPart = false;
592            }
593            String queryField = StringUtils.join(queryFieldParts, '.');
594            String projectionField = StringUtils.join(projectionFieldParts, '.');
595            queryField = stripElemMatchPrefix(queryField);
596            return new FieldInfo(prop, prop, queryField, projectionField, type);
597        }
598    }
599
600    protected FieldInfo parseACP(String prop, String[] parts) {
601        if (parts.length != 3) {
602            throw new QueryParseException("No such property: " + prop);
603        }
604        String wildcard = parts[1];
605        if (NumberUtils.isDigits(wildcard)) {
606            throw new QueryParseException("Cannot use explicit index in ACLs: " + prop);
607        }
608        String last = parts[2];
609        String queryField;
610        if (NXQL.ECM_ACL_NAME.equals(last)) {
611            queryField = KEY_ACP + "." + KEY_ACL_NAME;
612        } else {
613            String fieldLast = DBSSession.convToInternalAce(last);
614            if (fieldLast == null) {
615                throw new QueryParseException("No such property: " + prop);
616            }
617            queryField = KEY_ACP + "." + KEY_ACL + "." + fieldLast;
618        }
619        Type type = DBSSession.getType(last);
620        queryField = stripElemMatchPrefix(queryField);
621        return new FieldInfo(prop, prop, queryField, queryField, type);
622    }
623
624    protected boolean isMixinTypes(FieldInfo fieldInfo) {
625        return fieldInfo.queryField.equals(KEY_MIXIN_TYPES);
626    }
627
628    protected Set<String> getMixinDocumentTypes(String mixin) {
629        Set<String> types = schemaManager.getDocumentTypeNamesForFacet(mixin);
630        return types == null ? Collections.emptySet() : types;
631    }
632
633    protected List<String> getDocumentTypes() {
634        // TODO precompute in SchemaManager
635        if (documentTypes == null) {
636            documentTypes = new ArrayList<>();
637            for (DocumentType docType : schemaManager.getDocumentTypes()) {
638                documentTypes.add(docType.getName());
639            }
640        }
641        return documentTypes;
642    }
643
644    protected boolean isNeverPerInstanceMixin(String mixin) {
645        return schemaManager.getNoPerDocumentQueryFacets().contains(mixin);
646    }
647
648    /**
649     * Matches the mixin types against a list of values.
650     * <p>
651     * Used for:
652     * <ul>
653     * <li>ecm:mixinTypes = 'Foo'
654     * <li>ecm:mixinTypes != 'Foo'
655     * <li>ecm:mixinTypes IN ('Foo', 'Bar')
656     * <li>ecm:mixinTypes NOT IN ('Foo', 'Bar')
657     * </ul>
658     * <p>
659     * ecm:mixinTypes IN ('Foo', 'Bar')
660     *
661     * <pre>
662     * { "$or" : [ { "ecm:primaryType" : { "$in" : [ ... types with Foo or Bar ...]}} ,
663     *             { "ecm:mixinTypes" : { "$in" : [ "Foo" , "Bar]}}]}
664     * </pre>
665     *
666     * <p>
667     * ecm:mixinTypes NOT IN ('Foo', 'Bar')
668     *
669     * <pre>
670     * { "$and" : [ { "ecm:primaryType" : { "$in" : [ ... types without Foo nor Bar ...]}} ,
671     *              { "ecm:mixinTypes" : { "$nin" : [ "Foo" , "Bar]}}]}
672     * </pre>
673     */
674    public Document walkMixinTypes(List<String> mixins, boolean include) {
675        /*
676         * Primary types that match.
677         */
678        Set<String> matchPrimaryTypes;
679        if (include) {
680            matchPrimaryTypes = new HashSet<>();
681            for (String mixin : mixins) {
682                matchPrimaryTypes.addAll(getMixinDocumentTypes(mixin));
683            }
684        } else {
685            matchPrimaryTypes = new HashSet<>(getDocumentTypes());
686            for (String mixin : mixins) {
687                matchPrimaryTypes.removeAll(getMixinDocumentTypes(mixin));
688            }
689        }
690        /*
691         * Instance mixins that match.
692         */
693        Set<String> matchMixinTypes = new HashSet<>();
694        for (String mixin : mixins) {
695            if (!isNeverPerInstanceMixin(mixin)) {
696                matchMixinTypes.add(mixin);
697            }
698        }
699        /*
700         * MongoDB query generation.
701         */
702        // match on primary type
703        Document p = new Document(KEY_PRIMARY_TYPE, new Document(MongoDBOperators.IN, matchPrimaryTypes));
704        // match on mixin types
705        // $in/$nin with an array matches if any/no element of the array matches
706        String innin = include ? MongoDBOperators.IN : MongoDBOperators.NIN;
707        Document m = new Document(KEY_MIXIN_TYPES, new Document(innin, matchMixinTypes));
708        // and/or between those
709        String op = include ? MongoDBOperators.OR : MongoDBOperators.AND;
710        return new Document(op, Arrays.asList(p, m));
711    }
712
713}