/*
* (C) Copyright 2014-2016 Nuxeo SA (http://nuxeo.com/) and others.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* Contributors:
* Florent Guillaume
*/
package org.nuxeo.ecm.core.storage.mongodb;
import static java.lang.Boolean.FALSE;
import static java.lang.Boolean.TRUE;
import static org.nuxeo.ecm.core.storage.dbs.DBSDocument.KEY_ACL;
import static org.nuxeo.ecm.core.storage.dbs.DBSDocument.KEY_ACL_NAME;
import static org.nuxeo.ecm.core.storage.dbs.DBSDocument.KEY_ACP;
import static org.nuxeo.ecm.core.storage.dbs.DBSDocument.KEY_FULLTEXT_SCORE;
import static org.nuxeo.ecm.core.storage.mongodb.MongoDBRepository.MONGODB_ID;
import static org.nuxeo.ecm.core.storage.mongodb.MongoDBRepository.MONGODB_META;
import static org.nuxeo.ecm.core.storage.mongodb.MongoDBRepository.MONGODB_TEXT_SCORE;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.apache.commons.lang.StringUtils;
import org.apache.commons.lang.math.NumberUtils;
import org.nuxeo.ecm.core.query.QueryParseException;
import org.nuxeo.ecm.core.query.sql.NXQL;
import org.nuxeo.ecm.core.query.sql.model.BooleanLiteral;
import org.nuxeo.ecm.core.query.sql.model.DateLiteral;
import org.nuxeo.ecm.core.query.sql.model.DoubleLiteral;
import org.nuxeo.ecm.core.query.sql.model.Expression;
import org.nuxeo.ecm.core.query.sql.model.Function;
import org.nuxeo.ecm.core.query.sql.model.IntegerLiteral;
import org.nuxeo.ecm.core.query.sql.model.Literal;
import org.nuxeo.ecm.core.query.sql.model.LiteralList;
import org.nuxeo.ecm.core.query.sql.model.MultiExpression;
import org.nuxeo.ecm.core.query.sql.model.Operand;
import org.nuxeo.ecm.core.query.sql.model.Operator;
import org.nuxeo.ecm.core.query.sql.model.OrderByClause;
import org.nuxeo.ecm.core.query.sql.model.OrderByExpr;
import org.nuxeo.ecm.core.query.sql.model.Reference;
import org.nuxeo.ecm.core.query.sql.model.SelectClause;
import org.nuxeo.ecm.core.query.sql.model.StringLiteral;
import org.nuxeo.ecm.core.schema.DocumentType;
import org.nuxeo.ecm.core.schema.SchemaManager;
import org.nuxeo.ecm.core.schema.types.ComplexType;
import org.nuxeo.ecm.core.schema.types.Field;
import org.nuxeo.ecm.core.schema.types.ListType;
import org.nuxeo.ecm.core.schema.types.Schema;
import org.nuxeo.ecm.core.schema.types.Type;
import org.nuxeo.ecm.core.schema.types.primitives.BooleanType;
import org.nuxeo.ecm.core.schema.types.primitives.DateType;
import org.nuxeo.ecm.core.storage.ExpressionEvaluator;
import org.nuxeo.ecm.core.storage.ExpressionEvaluator.PathResolver;
import org.nuxeo.ecm.core.storage.FulltextQueryAnalyzer;
import org.nuxeo.ecm.core.storage.FulltextQueryAnalyzer.FulltextQuery;
import org.nuxeo.ecm.core.storage.FulltextQueryAnalyzer.Op;
import org.nuxeo.ecm.core.storage.dbs.DBSDocument;
import org.nuxeo.ecm.core.storage.dbs.DBSSession;
import org.nuxeo.runtime.api.Framework;
import com.mongodb.BasicDBObject;
import com.mongodb.DBObject;
import com.mongodb.QueryOperators;
/**
* Query builder for a MongoDB query from an {@link Expression}.
*
* @since 5.9.4
*/
public class MongoDBQueryBuilder {
private static final Long ZERO = Long.valueOf(0);
private static final Long ONE = Long.valueOf(1);
private static final Long MINUS_ONE = Long.valueOf(-1);
protected static final String DATE_CAST = "DATE";
protected final AtomicInteger counter = new AtomicInteger();
protected final SchemaManager schemaManager;
protected final MongoDBConverter converter;
protected final String idKey;
protected List<String> documentTypes;
protected final Expression expression;
protected final SelectClause selectClause;
protected final OrderByClause orderByClause;
protected final PathResolver pathResolver;
public boolean hasFulltext;
public boolean sortOnFulltextScore;
protected DBObject query;
protected DBObject orderBy;
protected DBObject projection;
boolean projectionHasWildcard;
private boolean fulltextSearchDisabled;
public MongoDBQueryBuilder(MongoDBRepository repository, Expression expression, SelectClause selectClause,
OrderByClause orderByClause, PathResolver pathResolver, boolean fulltextSearchDisabled) {
schemaManager = Framework.getLocalService(SchemaManager.class);
converter = repository.converter;
idKey = repository.idKey;
this.expression = expression;
this.selectClause = selectClause;
this.orderByClause = orderByClause;
this.pathResolver = pathResolver;
this.fulltextSearchDisabled = fulltextSearchDisabled;
}
public void walk() {
query = walkExpression(expression); // computes hasFulltext
walkOrderBy(); // computes sortOnFulltextScore
walkProjection(); // needs hasFulltext and sortOnFulltextScore
}
public DBObject getQuery() {
return query;
}
public DBObject getOrderBy() {
return orderBy;
}
public DBObject getProjection() {
return projection;
}
public boolean hasProjectionWildcard() {
return projectionHasWildcard;
}
protected void walkOrderBy() {
sortOnFulltextScore = false;
if (orderByClause == null) {
orderBy = null;
} else {
orderBy = new BasicDBObject();
for (OrderByExpr ob : orderByClause.elements) {
Reference ref = ob.reference;
boolean desc = ob.isDescending;
String field = walkReference(ref).queryField;
if (!orderBy.containsField(field)) {
Object value;
if (KEY_FULLTEXT_SCORE.equals(field)) {
if (!desc) {
throw new QueryParseException("Cannot sort by " + NXQL.ECM_FULLTEXT_SCORE + " ascending");
}
sortOnFulltextScore = true;
value = new BasicDBObject(MONGODB_META, MONGODB_TEXT_SCORE);
} else {
value = desc ? MINUS_ONE : ONE;
}
orderBy.put(field, value);
}
}
if (sortOnFulltextScore && ((BasicDBObject) orderBy).size() > 1) {
throw new QueryParseException("Cannot sort by " + NXQL.ECM_FULLTEXT_SCORE + " and other criteria");
}
}
}
protected void walkProjection() {
projection = new BasicDBObject();
boolean projectionOnFulltextScore = false;
for (Operand op : selectClause.getSelectList().values()) {
if (!(op instanceof Reference)) {
throw new QueryParseException("Projection not supported: " + op);
}
FieldInfo fieldInfo = walkReference((Reference) op);
projection.put(fieldInfo.projectionField, ONE);
if (fieldInfo.hasWildcard) {
projectionHasWildcard = true;
}
if (fieldInfo.projectionField.equals(KEY_FULLTEXT_SCORE)) {
projectionOnFulltextScore = true;
}
}
if (projectionOnFulltextScore || sortOnFulltextScore) {
if (!hasFulltext) {
throw new QueryParseException(NXQL.ECM_FULLTEXT_SCORE + " cannot be used without " + NXQL.ECM_FULLTEXT);
}
projection.put(KEY_FULLTEXT_SCORE, new BasicDBObject(MONGODB_META, MONGODB_TEXT_SCORE));
}
}
public DBObject walkExpression(Expression expr) {
Operator op = expr.operator;
Operand lvalue = expr.lvalue;
Operand rvalue = expr.rvalue;
Reference ref = lvalue instanceof Reference ? (Reference) lvalue : null;
String name = ref != null ? ref.name : null;
String cast = ref != null ? ref.cast : null;
if (DATE_CAST.equals(cast)) {
checkDateLiteralForCast(op, rvalue, name);
}
if (op == Operator.STARTSWITH) {
return walkStartsWith(lvalue, rvalue);
} else if (NXQL.ECM_PATH.equals(name)) {
return walkEcmPath(op, rvalue);
} else if (NXQL.ECM_ANCESTORID.equals(name)) {
return walkAncestorId(op, rvalue);
} else if (name != null && name.startsWith(NXQL.ECM_FULLTEXT) && !NXQL.ECM_FULLTEXT_JOBID.equals(name)) {
return walkEcmFulltext(name, op, rvalue);
} else if (op == Operator.SUM) {
throw new UnsupportedOperationException("SUM");
} else if (op == Operator.SUB) {
throw new UnsupportedOperationException("SUB");
} else if (op == Operator.MUL) {
throw new UnsupportedOperationException("MUL");
} else if (op == Operator.DIV) {
throw new UnsupportedOperationException("DIV");
} else if (op == Operator.LT) {
return walkLt(lvalue, rvalue);
} else if (op == Operator.GT) {
return walkGt(lvalue, rvalue);
} else if (op == Operator.EQ) {
return walkEq(lvalue, rvalue);
} else if (op == Operator.NOTEQ) {
return walkNotEq(lvalue, rvalue);
} else if (op == Operator.LTEQ) {
return walkLtEq(lvalue, rvalue);
} else if (op == Operator.GTEQ) {
return walkGtEq(lvalue, rvalue);
} else if (op == Operator.AND) {
if (expr instanceof MultiExpression) {
return walkMultiExpression((MultiExpression) expr);
} else {
return walkAnd(lvalue, rvalue);
}
} else if (op == Operator.NOT) {
return walkNot(lvalue);
} else if (op == Operator.OR) {
return walkOr(lvalue, rvalue);
} else if (op == Operator.LIKE) {
return walkLike(lvalue, rvalue, true, false);
} else if (op == Operator.ILIKE) {
return walkLike(lvalue, rvalue, true, true);
} else if (op == Operator.NOTLIKE) {
return walkLike(lvalue, rvalue, false, false);
} else if (op == Operator.NOTILIKE) {
return walkLike(lvalue, rvalue, false, true);
} else if (op == Operator.IN) {
return walkIn(lvalue, rvalue, true);
} else if (op == Operator.NOTIN) {
return walkIn(lvalue, rvalue, false);
} else if (op == Operator.ISNULL) {
return walkIsNull(lvalue);
} else if (op == Operator.ISNOTNULL) {
return walkIsNotNull(lvalue);
} else if (op == Operator.BETWEEN) {
return walkBetween(lvalue, rvalue, true);
} else if (op == Operator.NOTBETWEEN) {
return walkBetween(lvalue, rvalue, false);
} else {
throw new QueryParseException("Unknown operator: " + op);
}
}
protected void checkDateLiteralForCast(Operator op, Operand value, String name) {
if (op == Operator.BETWEEN || op == Operator.NOTBETWEEN) {
LiteralList l = (LiteralList) value;
checkDateLiteralForCast(l.get(0), name);
checkDateLiteralForCast(l.get(1), name);
} else {
checkDateLiteralForCast(value, name);
}
}
protected void checkDateLiteralForCast(Operand value, String name) {
if (value instanceof DateLiteral && !((DateLiteral) value).onlyDate) {
throw new QueryParseException("DATE() cast must be used with DATE literal, not TIMESTAMP: " + name);
}
}
protected DBObject walkEcmPath(Operator op, Operand rvalue) {
if (op != Operator.EQ && op != Operator.NOTEQ) {
throw new QueryParseException(NXQL.ECM_PATH + " requires = or <> operator");
}
if (!(rvalue instanceof StringLiteral)) {
throw new QueryParseException(NXQL.ECM_PATH + " requires literal path as right argument");
}
String path = ((StringLiteral) rvalue).value;
if (path.length() > 1 && path.endsWith("/")) {
path = path.substring(0, path.length() - 1);
}
String id = pathResolver.getIdForPath(path);
if (id == null) {
// no such path
// TODO XXX do better
return new BasicDBObject(MONGODB_ID, "__nosuchid__");
}
if (op == Operator.EQ) {
return new BasicDBObject(idKey, id);
} else {
return new BasicDBObject(idKey, new BasicDBObject(QueryOperators.NE, id));
}
}
protected DBObject walkAncestorId(Operator op, Operand rvalue) {
if (op != Operator.EQ && op != Operator.NOTEQ) {
throw new QueryParseException(NXQL.ECM_ANCESTORID + " requires = or <> operator");
}
if (!(rvalue instanceof StringLiteral)) {
throw new QueryParseException(NXQL.ECM_ANCESTORID + " requires literal id as right argument");
}
String ancestorId = ((StringLiteral) rvalue).value;
if (op == Operator.EQ) {
return new BasicDBObject(DBSDocument.KEY_ANCESTOR_IDS, ancestorId);
} else {
return new BasicDBObject(DBSDocument.KEY_ANCESTOR_IDS, new BasicDBObject(QueryOperators.NE, ancestorId));
}
}
protected DBObject walkEcmFulltext(String name, Operator op, Operand rvalue) {
if (op != Operator.EQ && op != Operator.LIKE) {
throw new QueryParseException(NXQL.ECM_FULLTEXT + " requires = or LIKE operator");
}
if (!(rvalue instanceof StringLiteral)) {
throw new QueryParseException(NXQL.ECM_FULLTEXT + " requires literal string as right argument");
}
if (fulltextSearchDisabled) {
throw new QueryParseException("Fulltext search disabled by configuration");
}
String fulltextQuery = ((StringLiteral) rvalue).value;
if (name.equals(NXQL.ECM_FULLTEXT)) {
// standard fulltext query
hasFulltext = true;
String ft = getMongoDBFulltextQuery(fulltextQuery);
if (ft == null) {
// empty query, matches nothing
return new BasicDBObject(MONGODB_ID, "__nosuchid__");
}
DBObject textSearch = new BasicDBObject();
textSearch.put(QueryOperators.SEARCH, ft);
// TODO language?
return new BasicDBObject(QueryOperators.TEXT, textSearch);
} else {
// secondary index match with explicit field
// do a regexp on the field
if (name.charAt(NXQL.ECM_FULLTEXT.length()) != '.') {
throw new QueryParseException(name + " has incorrect syntax" + " for a secondary fulltext index");
}
String prop = name.substring(NXQL.ECM_FULLTEXT.length() + 1);
String ft = fulltextQuery.replace(" ", "%");
rvalue = new StringLiteral(ft);
return walkLike(new Reference(prop), rvalue, true, true);
}
}
// public static for tests
public static String getMongoDBFulltextQuery(String query) {
FulltextQuery ft = FulltextQueryAnalyzer.analyzeFulltextQuery(query);
if (ft == null) {
return null;
}
// translate into MongoDB syntax
return translateFulltext(ft, false);
}
/**
* Transforms the NXQL fulltext syntax into MongoDB syntax.
* <p>
* The MongoDB fulltext query syntax is badly documented, but is actually the following:
* <ul>
* <li>a term is a word,
* <li>a phrase is a set of spaced-separated words enclosed in double quotes,
* <li>negation is done by prepending a -,
* <li>the query is a space-separated set of terms, negated terms, phrases, or negated phrases.
* <li>all the words of non-negated phrases are also added to the terms.
* </ul>
* <p>
* The matching algorithm is (excluding stemming and stop words):
* <ul>
* <li>filter out documents with the negative terms, the negative phrases, or missing the phrases,
* <li>then if any term is present in the document then it's a match.
* </ul>
*/
protected static String translateFulltext(FulltextQuery ft, boolean and) {
List<String> buf = new ArrayList<>();
translateFulltext(ft, buf, and);
return StringUtils.join(buf, ' ');
}
protected static void translateFulltext(FulltextQuery ft, List<String> buf, boolean and) {
if (ft.op == Op.OR) {
for (FulltextQuery term : ft.terms) {
// don't quote words for OR
translateFulltext(term, buf, false);
}
} else if (ft.op == Op.AND) {
for (FulltextQuery term : ft.terms) {
// quote words for AND
translateFulltext(term, buf, true);
}
} else {
String neg;
if (ft.op == Op.NOTWORD) {
neg = "-";
} else { // Op.WORD
neg = "";
}
String word = ft.word.toLowerCase();
if (ft.isPhrase() || and) {
buf.add(neg + '"' + word + '"');
} else {
buf.add(neg + word);
}
}
}
public DBObject walkNot(Operand value) {
Object val = walkOperand(value);
Object not = pushDownNot(val);
if (!(not instanceof DBObject)) {
throw new QueryParseException("Cannot do NOT on: " + val);
}
return (DBObject) not;
}
protected Object pushDownNot(Object object) {
if (!(object instanceof DBObject)) {
throw new QueryParseException("Cannot do NOT on: " + object);
}
DBObject ob = (DBObject) object;
Set<String> keySet = ob.keySet();
if (keySet.size() != 1) {
throw new QueryParseException("Cannot do NOT on: " + ob);
}
String key = keySet.iterator().next();
Object value = ob.get(key);
if (!key.startsWith("$")) {
if (value instanceof DBObject) {
// push down inside dbobject
return new BasicDBObject(key, pushDownNot(value));
} else {
// k = v -> k != v
return new BasicDBObject(key, new BasicDBObject(QueryOperators.NE, value));
}
}
if (QueryOperators.NE.equals(key)) {
// NOT k != v -> k = v
return value;
}
if (QueryOperators.NOT.equals(key)) {
// NOT NOT v -> v
return value;
}
if (QueryOperators.AND.equals(key) || QueryOperators.OR.equals(key)) {
// boolean algebra
// NOT (v1 AND v2) -> NOT v1 OR NOT v2
// NOT (v1 OR v2) -> NOT v1 AND NOT v2
String op = QueryOperators.AND.equals(key) ? QueryOperators.OR : QueryOperators.AND;
List<Object> list = (List<Object>) value;
for (int i = 0; i < list.size(); i++) {
list.set(i, pushDownNot(list.get(i)));
}
return new BasicDBObject(op, list);
}
if (QueryOperators.IN.equals(key) || QueryOperators.NIN.equals(key)) {
// boolean algebra
// IN <-> NIN
String op = QueryOperators.IN.equals(key) ? QueryOperators.NIN : QueryOperators.IN;
return new BasicDBObject(op, value);
}
if (QueryOperators.LT.equals(key) || QueryOperators.GT.equals(key) || QueryOperators.LTE.equals(key)
|| QueryOperators.GTE.equals(key)) {
// TODO use inverse operators?
return new BasicDBObject(QueryOperators.NOT, ob);
}
throw new QueryParseException("Unknown operator for NOT: " + key);
}
public DBObject walkIsNull(Operand value) {
FieldInfo fieldInfo = walkReference(value);
return new FieldInfoDBObject(fieldInfo, null);
}
public DBObject walkIsNotNull(Operand value) {
FieldInfo fieldInfo = walkReference(value);
return new FieldInfoDBObject(fieldInfo, new BasicDBObject(QueryOperators.NE, null));
}
public DBObject walkMultiExpression(MultiExpression expr) {
return walkAnd(expr.values);
}
public DBObject walkAnd(Operand lvalue, Operand rvalue) {
return walkAnd(Arrays.asList(lvalue, rvalue));
}
protected DBObject walkAnd(List<Operand> values) {
List<Object> list = walkOperandList(values);
// check wildcards in the operands, extract common prefixes to use $elemMatch
Map<String, List<FieldInfoDBObject>> propBaseKeyToDBOs = new LinkedHashMap<>();
Map<String, String> propBaseKeyToFieldBase = new HashMap<>();
for (Iterator<Object> it = list.iterator(); it.hasNext();) {
Object ob = it.next();
if (ob instanceof FieldInfoDBObject) {
FieldInfoDBObject fidbo = (FieldInfoDBObject) ob;
FieldInfo fieldInfo = fidbo.fieldInfo;
if (fieldInfo.hasWildcard) {
if (fieldInfo.fieldSuffix != null && fieldInfo.fieldSuffix.contains("*")) {
// a double wildcard of the form foo/*/bar/* is not a problem if bar is an array
// TODO prevent deep complex multiple wildcards
// throw new QueryParseException("Cannot use two wildcards: " + fieldInfo.prop);
}
// generate a key unique per correlation for this element match
String wildcardNumber = fieldInfo.fieldWildcard;
if (wildcardNumber.isEmpty()) {
// negative to not collide with regular correlated wildcards
wildcardNumber = String.valueOf(-counter.incrementAndGet());
}
String propBaseKey = fieldInfo.fieldPrefix + "/*" + wildcardNumber;
// store object for this key
List<FieldInfoDBObject> dbos = propBaseKeyToDBOs.get(propBaseKey);
if (dbos == null) {
propBaseKeyToDBOs.put(propBaseKey, dbos = new LinkedList<>());
}
dbos.add(fidbo);
// remember for which field base this is
String fieldBase = fieldInfo.fieldPrefix.replace("/", ".");
propBaseKeyToFieldBase.put(propBaseKey, fieldBase);
// remove from list, will be re-added later through propBaseKeyToDBOs
it.remove();
}
}
}
// generate $elemMatch items for correlated queries
for (Entry<String, List<FieldInfoDBObject>> es : propBaseKeyToDBOs.entrySet()) {
String propBaseKey = es.getKey();
List<FieldInfoDBObject> fidbos = es.getValue();
if (fidbos.size() == 1) {
// regular uncorrelated match
list.addAll(fidbos);
} else {
DBObject elemMatch = new BasicDBObject();
for (FieldInfoDBObject fidbo : fidbos) {
// truncate field name to just the suffix
FieldInfo fieldInfo = fidbo.fieldInfo;
Object value = fidbo.get(fieldInfo.queryField);
String fieldSuffix = fieldInfo.fieldSuffix.replace("/", ".");
if (elemMatch.containsField(fieldSuffix)) {
// ecm:acl/*1/principal = 'bob' AND ecm:acl/*1/principal = 'steve'
// cannot match
// TODO do better
value = "__NOSUCHVALUE__";
}
elemMatch.put(fieldSuffix, value);
}
String fieldBase = propBaseKeyToFieldBase.get(propBaseKey);
BasicDBObject dbo = new BasicDBObject(fieldBase,
new BasicDBObject(QueryOperators.ELEM_MATCH, elemMatch));
list.add(dbo);
}
}
if (list.size() == 1) {
return (DBObject) list.get(0);
} else {
return new BasicDBObject(QueryOperators.AND, list);
}
}
public DBObject walkOr(Operand lvalue, Operand rvalue) {
Object left = walkOperand(lvalue);
Object right = walkOperand(rvalue);
List<Object> list = new ArrayList<>(Arrays.asList(left, right));
return new BasicDBObject(QueryOperators.OR, list);
}
protected Object checkBoolean(FieldInfo fieldInfo, Object right) {
if (fieldInfo.isBoolean()) {
// convert 0 / 1 to actual booleans
if (right instanceof Long) {
if (ZERO.equals(right)) {
right = fieldInfo.isTrueOrNullBoolean ? null : FALSE;
} else if (ONE.equals(right)) {
right = TRUE;
} else {
throw new QueryParseException("Invalid boolean: " + right);
}
}
}
return right;
}
public DBObject walkEq(Operand lvalue, Operand rvalue) {
FieldInfo fieldInfo = walkReference(lvalue);
Object right = walkOperand(rvalue);
if (isMixinTypes(fieldInfo)) {
if (!(right instanceof String)) {
throw new QueryParseException("Invalid EQ rhs: " + rvalue);
}
return walkMixinTypes(Collections.singletonList((String) right), true);
}
right = checkBoolean(fieldInfo, right);
// TODO check list fields
return new FieldInfoDBObject(fieldInfo, right);
}
public DBObject walkNotEq(Operand lvalue, Operand rvalue) {
FieldInfo fieldInfo = walkReference(lvalue);
Object right = walkOperand(rvalue);
if (isMixinTypes(fieldInfo)) {
if (!(right instanceof String)) {
throw new QueryParseException("Invalid NE rhs: " + rvalue);
}
return walkMixinTypes(Collections.singletonList((String) right), false);
}
right = checkBoolean(fieldInfo, right);
// TODO check list fields
return new FieldInfoDBObject(fieldInfo, new BasicDBObject(QueryOperators.NE, right));
}
public DBObject walkLt(Operand lvalue, Operand rvalue) {
FieldInfo fieldInfo = walkReference(lvalue);
Object right = walkOperand(rvalue);
return new FieldInfoDBObject(fieldInfo, new BasicDBObject(QueryOperators.LT, right));
}
public DBObject walkGt(Operand lvalue, Operand rvalue) {
FieldInfo fieldInfo = walkReference(lvalue);
Object right = walkOperand(rvalue);
return new FieldInfoDBObject(fieldInfo, new BasicDBObject(QueryOperators.GT, right));
}
public DBObject walkLtEq(Operand lvalue, Operand rvalue) {
FieldInfo fieldInfo = walkReference(lvalue);
Object right = walkOperand(rvalue);
return new FieldInfoDBObject(fieldInfo, new BasicDBObject(QueryOperators.LTE, right));
}
public DBObject walkGtEq(Operand lvalue, Operand rvalue) {
FieldInfo fieldInfo = walkReference(lvalue);
Object right = walkOperand(rvalue);
return new FieldInfoDBObject(fieldInfo, new BasicDBObject(QueryOperators.GTE, right));
}
public DBObject walkBetween(Operand lvalue, Operand rvalue, boolean positive) {
LiteralList l = (LiteralList) rvalue;
FieldInfo fieldInfo = walkReference(lvalue);
Object left = walkOperand(l.get(0));
Object right = walkOperand(l.get(1));
if (positive) {
DBObject range = new BasicDBObject();
range.put(QueryOperators.GTE, left);
range.put(QueryOperators.LTE, right);
return new FieldInfoDBObject(fieldInfo, range);
} else {
DBObject a = new FieldInfoDBObject(fieldInfo, new BasicDBObject(QueryOperators.LT, left));
DBObject b = new FieldInfoDBObject(fieldInfo, new BasicDBObject(QueryOperators.GT, right));
return new BasicDBObject(QueryOperators.OR, Arrays.asList(a, b));
}
}
public DBObject walkIn(Operand lvalue, Operand rvalue, boolean positive) {
FieldInfo fieldInfo = walkReference(lvalue);
Object right = walkOperand(rvalue);
if (!(right instanceof List)) {
throw new QueryParseException("Invalid IN, right hand side must be a list: " + rvalue);
}
if (isMixinTypes(fieldInfo)) {
return walkMixinTypes((List<String>) right, positive);
}
// TODO check list fields
List<Object> list = (List<Object>) right;
return new FieldInfoDBObject(fieldInfo,
new BasicDBObject(positive ? QueryOperators.IN : QueryOperators.NIN, list));
}
public DBObject walkLike(Operand lvalue, Operand rvalue, boolean positive, boolean caseInsensitive) {
FieldInfo fieldInfo = walkReference(lvalue);
if (!(rvalue instanceof StringLiteral)) {
throw new QueryParseException("Invalid LIKE/ILIKE, right hand side must be a string: " + rvalue);
}
// TODO check list fields
String like = walkStringLiteral((StringLiteral) rvalue);
String regex = ExpressionEvaluator.likeToRegex(like);
int flags = caseInsensitive ? Pattern.CASE_INSENSITIVE : 0;
Pattern pattern = Pattern.compile(regex, flags);
Object value;
if (positive) {
value = pattern;
} else {
value = new BasicDBObject(QueryOperators.NOT, pattern);
}
return new FieldInfoDBObject(fieldInfo, value);
}
public Object walkOperand(Operand op) {
if (op instanceof Literal) {
return walkLiteral((Literal) op);
} else if (op instanceof LiteralList) {
return walkLiteralList((LiteralList) op);
} else if (op instanceof Function) {
return walkFunction((Function) op);
} else if (op instanceof Expression) {
return walkExpression((Expression) op);
} else if (op instanceof Reference) {
return walkReference((Reference) op);
} else {
throw new QueryParseException("Unknown operand: " + op);
}
}
public Object walkLiteral(Literal lit) {
if (lit instanceof BooleanLiteral) {
return walkBooleanLiteral((BooleanLiteral) lit);
} else if (lit instanceof DateLiteral) {
return walkDateLiteral((DateLiteral) lit);
} else if (lit instanceof DoubleLiteral) {
return walkDoubleLiteral((DoubleLiteral) lit);
} else if (lit instanceof IntegerLiteral) {
return walkIntegerLiteral((IntegerLiteral) lit);
} else if (lit instanceof StringLiteral) {
return walkStringLiteral((StringLiteral) lit);
} else {
throw new QueryParseException("Unknown literal: " + lit);
}
}
public Object walkBooleanLiteral(BooleanLiteral lit) {
return Boolean.valueOf(lit.value);
}
public Date walkDateLiteral(DateLiteral lit) {
return lit.value.toDate(); // TODO onlyDate
}
public Double walkDoubleLiteral(DoubleLiteral lit) {
return Double.valueOf(lit.value);
}
public Long walkIntegerLiteral(IntegerLiteral lit) {
return Long.valueOf(lit.value);
}
public String walkStringLiteral(StringLiteral lit) {
return lit.value;
}
public List<Object> walkLiteralList(LiteralList litList) {
List<Object> list = new ArrayList<>(litList.size());
for (Literal lit : litList) {
list.add(walkLiteral(lit));
}
return list;
}
protected List<Object> walkOperandList(List<Operand> values) {
List<Object> list = new LinkedList<>();
for (Operand value : values) {
list.add(walkOperand(value));
}
return list;
}
public Object walkFunction(Function func) {
throw new UnsupportedOperationException(func.name);
}
public DBObject walkStartsWith(Operand lvalue, Operand rvalue) {
if (!(lvalue instanceof Reference)) {
throw new QueryParseException("Invalid STARTSWITH query, left hand side must be a property: " + lvalue);
}
String name = ((Reference) lvalue).name;
if (!(rvalue instanceof StringLiteral)) {
throw new QueryParseException(
"Invalid STARTSWITH query, right hand side must be a literal path: " + rvalue);
}
String path = ((StringLiteral) rvalue).value;
if (path.length() > 1 && path.endsWith("/")) {
path = path.substring(0, path.length() - 1);
}
if (NXQL.ECM_PATH.equals(name)) {
return walkStartsWithPath(path);
} else {
return walkStartsWithNonPath(lvalue, path);
}
}
protected DBObject walkStartsWithPath(String path) {
// resolve path
String ancestorId = pathResolver.getIdForPath(path);
if (ancestorId == null) {
// no such path
// TODO XXX do better
return new BasicDBObject(MONGODB_ID, "__nosuchid__");
}
return new BasicDBObject(DBSDocument.KEY_ANCESTOR_IDS, ancestorId);
}
protected DBObject walkStartsWithNonPath(Operand lvalue, String path) {
FieldInfo fieldInfo = walkReference(lvalue);
DBObject eq = new FieldInfoDBObject(fieldInfo, path);
// escape except alphanumeric and others not needing escaping
String regex = path.replaceAll("([^a-zA-Z0-9 /])", "\\\\$1");
Pattern pattern = Pattern.compile(regex + "/.*");
DBObject like = new FieldInfoDBObject(fieldInfo, pattern);
return new BasicDBObject(QueryOperators.OR, Arrays.asList(eq, like));
}
protected FieldInfo walkReference(Operand value) {
if (!(value instanceof Reference)) {
throw new QueryParseException("Invalid query, left hand side must be a property: " + value);
}
return walkReference((Reference) value);
}
// non-canonical index syntax, for replaceAll
protected final static Pattern NON_CANON_INDEX = Pattern.compile("[^/\\[\\]]+" // name
+ "\\[(\\d+|\\*|\\*\\d+)\\]" // index in brackets
);
/**
* Canonicalizes a Nuxeo-xpath.
* <p>
* Replaces {@code a/foo[123]/b} with {@code a/123/b}
* <p>
* A star or a star followed by digits can be used instead of just the digits as well.
*
* @param xpath the xpath
* @return the canonicalized xpath.
*/
public static String canonicalXPath(String xpath) {
while (xpath.length() > 0 && xpath.charAt(0) == '/') {
xpath = xpath.substring(1);
}
if (xpath.indexOf('[') == -1) {
return xpath;
} else {
return NON_CANON_INDEX.matcher(xpath).replaceAll("$1");
}
}
/** Splits foo.*.bar into foo, *, bar and split foo.*1.bar into foo, *1, bar with the last bar part optional */
protected final static Pattern WILDCARD_SPLIT = Pattern.compile("([^*]*)\\.\\*(\\d*)(?:\\.(.*))?");
protected static class FieldInfo {
/** NXQL property. */
protected final String prop;
/** MongoDB field including wildcards (not used as-is). */
protected final String fullField;
/** MongoDB field for query. foo/0/bar -> foo.0.bar; foo / * / bar -> foo.bar */
protected final String queryField;
/** MongoDB field for projection. */
protected final String projectionField;
protected final Type type;
/**
* Boolean system properties only use TRUE or NULL, not FALSE, so queries must be updated accordingly.
*/
protected final boolean isTrueOrNullBoolean;
protected final boolean hasWildcard;
/** Prefix before the wildcard. */
protected final String fieldPrefix;
/** Wildcard part after * */
protected final String fieldWildcard;
/** Part after wildcard, may be null. */
protected final String fieldSuffix;
protected FieldInfo(String prop, String fullField, String queryField, String projectionField, Type type,
boolean isTrueOrNullBoolean) {
this.prop = prop;
this.fullField = fullField;
this.queryField = queryField;
this.projectionField = projectionField;
this.type = type;
this.isTrueOrNullBoolean = isTrueOrNullBoolean;
Matcher m = WILDCARD_SPLIT.matcher(fullField);
if (m.matches()) {
hasWildcard = true;
fieldPrefix = m.group(1);
fieldWildcard = m.group(2);
fieldSuffix = m.group(3);
} else {
hasWildcard = false;
fieldPrefix = fieldWildcard = fieldSuffix = null;
}
}
protected boolean isBoolean() {
return type instanceof BooleanType;
}
}
protected static class FieldInfoDBObject extends BasicDBObject {
private static final long serialVersionUID = 1L;
protected FieldInfo fieldInfo;
public FieldInfoDBObject(FieldInfo fieldInfo, Object value) {
super(fieldInfo.queryField, value);
this.fieldInfo = fieldInfo;
}
}
/**
* Returns the MongoDB field for this reference.
*/
public FieldInfo walkReference(Reference ref) {
FieldInfo fieldInfo = walkReference(ref.name);
if (DATE_CAST.equals(ref.cast)) {
Type type = fieldInfo.type;
if (!(type instanceof DateType
|| (type instanceof ListType && ((ListType) type).getFieldType() instanceof DateType))) {
throw new QueryParseException("Cannot cast to " + ref.cast + ": " + ref.name);
}
// fieldInfo.isDateCast = true;
}
return fieldInfo;
}
protected FieldInfo walkReference(String name) {
String prop = canonicalXPath(name);
String[] parts = prop.split("/");
if (prop.startsWith(NXQL.ECM_PREFIX)) {
if (prop.startsWith(NXQL.ECM_ACL + "/")) {
return parseACP(prop, parts);
}
// simple field
String field = DBSSession.convToInternal(prop);
Type type = DBSSession.getType(field);
String queryField = converter.keyToBson(field);
return new FieldInfo(prop, field, queryField, field, type, true);
} else {
String first = parts[0];
Field field = schemaManager.getField(first);
if (field == null) {
if (first.indexOf(':') > -1) {
throw new QueryParseException("No such property: " + name);
}
// check without prefix
// TODO precompute this in SchemaManagerImpl
for (Schema schema : schemaManager.getSchemas()) {
if (!StringUtils.isBlank(schema.getNamespace().prefix)) {
// schema with prefix, do not consider as candidate
continue;
}
if (schema != null) {
field = schema.getField(first);
if (field != null) {
break;
}
}
}
if (field == null) {
throw new QueryParseException("No such property: " + name);
}
}
Type type = field.getType();
// canonical name
parts[0] = field.getName().getPrefixedName();
// are there wildcards or list indexes?
List<String> queryFieldParts = new LinkedList<>(); // field for query
List<String> projectionFieldParts = new LinkedList<>(); // field for projection
boolean firstPart = true;
for (String part : parts) {
if (NumberUtils.isDigits(part)) {
// explicit list index
queryFieldParts.add(part);
type = ((ListType) type).getFieldType();
} else if (!part.startsWith("*")) {
// complex sub-property
queryFieldParts.add(part);
projectionFieldParts.add(part);
if (!firstPart) {
// we already computed the type of the first part
field = ((ComplexType) type).getField(part);
if (field == null) {
throw new QueryParseException("No such property: " + name);
}
type = field.getType();
}
} else {
// wildcard
type = ((ListType) type).getFieldType();
}
firstPart = false;
}
String fullField = StringUtils.join(parts, '.');
String queryField = StringUtils.join(queryFieldParts, '.');
String projectionField = StringUtils.join(projectionFieldParts, '.');
return new FieldInfo(prop, fullField, queryField, projectionField, type, false);
}
}
protected FieldInfo parseACP(String prop, String[] parts) {
if (parts.length != 3) {
throw new QueryParseException("No such property: " + prop);
}
String wildcard = parts[1];
if (NumberUtils.isDigits(wildcard)) {
throw new QueryParseException("Cannot use explicit index in ACLs: " + prop);
}
String last = parts[2];
String fullField;
String queryField;
String projectionField;
if (NXQL.ECM_ACL_NAME.equals(last)) {
fullField = KEY_ACP + "." + KEY_ACL_NAME;
queryField = KEY_ACP + "." + KEY_ACL_NAME;
// TODO remember wildcard correlation
} else {
String fieldLast = DBSSession.convToInternalAce(last);
if (fieldLast == null) {
throw new QueryParseException("No such property: " + prop);
}
fullField = KEY_ACP + "." + KEY_ACL + "." + wildcard + "." + fieldLast;
queryField = KEY_ACP + "." + KEY_ACL + "." + fieldLast;
}
Type type = DBSSession.getType(last);
projectionField = queryField;
return new FieldInfo(prop, fullField, queryField, projectionField, type, false);
}
protected boolean isMixinTypes(FieldInfo fieldInfo) {
return fieldInfo.queryField.equals(DBSDocument.KEY_MIXIN_TYPES);
}
protected Set<String> getMixinDocumentTypes(String mixin) {
Set<String> types = schemaManager.getDocumentTypeNamesForFacet(mixin);
return types == null ? Collections.emptySet() : types;
}
protected List<String> getDocumentTypes() {
// TODO precompute in SchemaManager
if (documentTypes == null) {
documentTypes = new ArrayList<>();
for (DocumentType docType : schemaManager.getDocumentTypes()) {
documentTypes.add(docType.getName());
}
}
return documentTypes;
}
protected boolean isNeverPerInstanceMixin(String mixin) {
return schemaManager.getNoPerDocumentQueryFacets().contains(mixin);
}
/**
* Matches the mixin types against a list of values.
* <p>
* Used for:
* <ul>
* <li>ecm:mixinTypes = 'Foo'
* <li>ecm:mixinTypes != 'Foo'
* <li>ecm:mixinTypes IN ('Foo', 'Bar')
* <li>ecm:mixinTypes NOT IN ('Foo', 'Bar')
* </ul>
* <p>
* ecm:mixinTypes IN ('Foo', 'Bar')
*
* <pre>
* { "$or" : [ { "ecm:primaryType" : { "$in" : [ ... types with Foo or Bar ...]}} ,
* { "ecm:mixinTypes" : { "$in" : [ "Foo" , "Bar]}}]}
* </pre>
*
* ecm:mixinTypes NOT IN ('Foo', 'Bar')
* <p>
*
* <pre>
* { "$and" : [ { "ecm:primaryType" : { "$in" : [ ... types without Foo nor Bar ...]}} ,
* { "ecm:mixinTypes" : { "$nin" : [ "Foo" , "Bar]}}]}
* </pre>
*/
public DBObject walkMixinTypes(List<String> mixins, boolean include) {
/*
* Primary types that match.
*/
Set<String> matchPrimaryTypes;
if (include) {
matchPrimaryTypes = new HashSet<>();
for (String mixin : mixins) {
matchPrimaryTypes.addAll(getMixinDocumentTypes(mixin));
}
} else {
matchPrimaryTypes = new HashSet<>(getDocumentTypes());
for (String mixin : mixins) {
matchPrimaryTypes.removeAll(getMixinDocumentTypes(mixin));
}
}
/*
* Instance mixins that match.
*/
Set<String> matchMixinTypes = new HashSet<>();
for (String mixin : mixins) {
if (!isNeverPerInstanceMixin(mixin)) {
matchMixinTypes.add(mixin);
}
}
/*
* MongoDB query generation.
*/
// match on primary type
DBObject p = new BasicDBObject(DBSDocument.KEY_PRIMARY_TYPE,
new BasicDBObject(QueryOperators.IN, matchPrimaryTypes));
// match on mixin types
// $in/$nin with an array matches if any/no element of the array matches
String innin = include ? QueryOperators.IN : QueryOperators.NIN;
DBObject m = new BasicDBObject(DBSDocument.KEY_MIXIN_TYPES, new BasicDBObject(innin, matchMixinTypes));
// and/or between those
String op = include ? QueryOperators.OR : QueryOperators.AND;
return new BasicDBObject(op, Arrays.asList(p, m));
}
}