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