/*
* (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:
* Tiry
* bdelbosc
*/
package org.nuxeo.elasticsearch.query;
import static org.nuxeo.elasticsearch.ElasticSearchConstants.FULLTEXT_FIELD;
import java.io.IOException;
import java.io.StringReader;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.apache.commons.lang.StringUtils;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.elasticsearch.common.geo.GeoPoint;
import org.elasticsearch.common.geo.GeoUtils;
import org.elasticsearch.common.geo.ShapeRelation;
import org.elasticsearch.common.xcontent.XContentBuilder;
import org.elasticsearch.common.xcontent.XContentParser;
import org.elasticsearch.common.xcontent.json.JsonXContent;
import org.elasticsearch.index.query.BoolQueryBuilder;
import org.elasticsearch.index.query.CommonTermsQueryBuilder;
import org.elasticsearch.index.query.MatchQueryBuilder;
import org.elasticsearch.index.query.MultiMatchQueryBuilder;
import org.elasticsearch.index.query.QueryBuilder;
import org.elasticsearch.index.query.QueryBuilders;
import org.elasticsearch.index.query.QueryStringQueryBuilder;
import org.elasticsearch.index.query.SimpleQueryStringBuilder;
import org.nuxeo.ecm.core.NXCore;
import org.nuxeo.ecm.core.api.CoreSession;
import org.nuxeo.ecm.core.api.DocumentModel;
import org.nuxeo.ecm.core.api.DocumentNotFoundException;
import org.nuxeo.ecm.core.api.IdRef;
import org.nuxeo.ecm.core.api.SortInfo;
import org.nuxeo.ecm.core.query.QueryParseException;
import org.nuxeo.ecm.core.query.sql.NXQL;
import org.nuxeo.ecm.core.query.sql.SQLQueryParser;
import org.nuxeo.ecm.core.query.sql.model.DefaultQueryVisitor;
import org.nuxeo.ecm.core.query.sql.model.EsHint;
import org.nuxeo.ecm.core.query.sql.model.Expression;
import org.nuxeo.ecm.core.query.sql.model.FromClause;
import org.nuxeo.ecm.core.query.sql.model.FromList;
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.OrderByExpr;
import org.nuxeo.ecm.core.query.sql.model.Reference;
import org.nuxeo.ecm.core.query.sql.model.SQLQuery;
import org.nuxeo.ecm.core.query.sql.model.SelectClause;
import org.nuxeo.ecm.core.schema.SchemaManager;
import org.nuxeo.ecm.core.schema.types.Field;
import org.nuxeo.ecm.core.schema.types.Type;
import org.nuxeo.ecm.core.storage.sql.jdbc.NXQLQueryMaker;
import org.nuxeo.runtime.api.Framework;
/**
* Helper class that holds the conversion logic. Conversion is based on the existing NXQL Parser, we are just using a
* visitor to build the ES request.
*/
final public class NxqlQueryConverter {
private static final Log log = LogFactory.getLog(NxqlQueryConverter.class);
private static final String SELECT_ALL = "SELECT * FROM Document";
private static final String SELECT_ALL_WHERE = "SELECT * FROM Document WHERE ";
private static final String SIMPLE_QUERY_PREFIX = "es: ";
private NxqlQueryConverter() {
}
public static QueryBuilder toESQueryBuilder(final String nxql) {
return toESQueryBuilder(nxql, null);
}
public static QueryBuilder toESQueryBuilder(final String nxql, final CoreSession session) {
final LinkedList<ExpressionBuilder> builders = new LinkedList<>();
SQLQuery nxqlQuery = getSqlQuery(nxql);
if (session != null) {
nxqlQuery = addSecurityPolicy(session, nxqlQuery);
}
final ExpressionBuilder ret = new ExpressionBuilder(null);
builders.add(ret);
final ArrayList<String> fromList = new ArrayList<>();
nxqlQuery.accept(new DefaultQueryVisitor() {
private static final long serialVersionUID = 1L;
@Override
public void visitFromClause(FromClause node) {
FromList elements = node.elements;
SchemaManager schemaManager = Framework.getLocalService(SchemaManager.class);
for (String type : elements.values()) {
if (NXQLQueryMaker.TYPE_DOCUMENT.equalsIgnoreCase(type)) {
// From Document means all doc types
fromList.clear();
return;
}
Set<String> types = schemaManager.getDocumentTypeNamesExtending(type);
if (types != null) {
fromList.addAll(types);
}
}
}
@Override
public void visitMultiExpression(MultiExpression node) {
for (Iterator<Operand> it = node.values.iterator(); it.hasNext();) {
it.next().accept(this);
if (it.hasNext()) {
node.operator.accept(this);
}
}
}
@Override
public void visitSelectClause(SelectClause node) {
// NOP
}
@Override
public void visitExpression(Expression node) {
Operator op = node.operator;
if (op == Operator.AND || op == Operator.OR || op == Operator.NOT) {
builders.add(new ExpressionBuilder(op.toString()));
super.visitExpression(node);
ExpressionBuilder expr = builders.removeLast();
if (!builders.isEmpty()) {
builders.getLast().merge(expr);
}
} else {
Reference ref = node.lvalue instanceof Reference ? (Reference) node.lvalue : null;
String name = ref != null ? ref.name : node.lvalue.toString();
String value = null;
if (node.rvalue instanceof Literal) {
value = ((Literal) node.rvalue).asString();
} else if (node.rvalue != null) {
value = node.rvalue.toString();
}
Object[] values = null;
if (node.rvalue instanceof LiteralList) {
LiteralList items = (LiteralList) node.rvalue;
values = new Object[items.size()];
int i = 0;
for (Literal item : items) {
values[i++] = item.asString();
}
}
// add expression to the last builder
EsHint hint = (ref != null) ? ref.esHint : null;
builders.getLast()
.add(makeQueryFromSimpleExpression(op.toString(), name, value, values, hint, session));
}
}
});
QueryBuilder queryBuilder = ret.get();
if (!fromList.isEmpty()) {
return QueryBuilders.boolQuery().must(queryBuilder).filter(makeQueryFromSimpleExpression("IN",
NXQL.ECM_PRIMARYTYPE, null, fromList.toArray(), null, null).filter);
}
return queryBuilder;
}
protected static SQLQuery getSqlQuery(String nxql) {
String query = completeQueryWithSelect(nxql);
SQLQuery nxqlQuery;
try {
nxqlQuery = SQLQueryParser.parse(new StringReader(query));
} catch (QueryParseException e) {
if (log.isDebugEnabled()) {
log.debug(e.getMessage() + " for query:\n" + query);
}
throw e;
}
return nxqlQuery;
}
protected static SQLQuery addSecurityPolicy(CoreSession session, SQLQuery query) {
Collection<SQLQuery.Transformer> transformers = NXCore.getSecurityService().getPoliciesQueryTransformers(
session.getRepositoryName());
for (SQLQuery.Transformer trans : transformers) {
query = trans.transform(session.getPrincipal(), query);
}
return query;
}
protected static String completeQueryWithSelect(String nxql) {
String query = (nxql == null) ? "" : nxql.trim();
if (query.isEmpty()) {
query = SELECT_ALL;
} else if (!query.toLowerCase().startsWith("select ")) {
query = SELECT_ALL_WHERE + nxql;
}
return query;
}
public static QueryAndFilter makeQueryFromSimpleExpression(String op, String nxqlName, Object value,
Object[] values, EsHint hint, CoreSession session) {
QueryBuilder query = null;
QueryBuilder filter = null;
String name = getFieldName(nxqlName, hint);
if (hint != null && hint.operator != null) {
if (hint.operator.startsWith("geo")) {
filter = makeHintFilter(name, values, hint);
} else {
query = makeHintQuery(name, value, hint);
}
} else if (nxqlName.startsWith(NXQL.ECM_FULLTEXT) && ("=".equals(op) || "!=".equals(op) || "<>".equals(op)
|| "LIKE".equals(op) || "NOT LIKE".equals(op))) {
query = makeFulltextQuery(nxqlName, (String) value, hint);
if ("!=".equals(op) || "<>".equals(op) || "NOT LIKE".equals(op)) {
filter = QueryBuilders.boolQuery().mustNot(query);
query = null;
}
} else if (nxqlName.startsWith(NXQL.ECM_ANCESTORID)) {
filter = makeAncestorIdFilter((String) value, session);
if ("!=".equals(op) || "<>".equals(op)) {
filter = QueryBuilders.boolQuery().mustNot(filter);
}
} else
switch (op) {
case "=":
filter = QueryBuilders.termQuery(name, value);
break;
case "<>":
case "!=":
filter = QueryBuilders.boolQuery().mustNot(QueryBuilders.termQuery(name, value));
break;
case ">":
filter = QueryBuilders.rangeQuery(name).gt(value);
break;
case "<":
filter = QueryBuilders.rangeQuery(name).lt(value);
break;
case ">=":
filter = QueryBuilders.rangeQuery(name).gte(value);
break;
case "<=":
filter = QueryBuilders.rangeQuery(name).lte(value);
break;
case "BETWEEN":
case "NOT BETWEEN":
filter = QueryBuilders.rangeQuery(name).from(values[0]).to(values[1]);
if (op.startsWith("NOT")) {
filter = QueryBuilders.boolQuery().mustNot(filter);
}
break;
case "IN":
case "NOT IN":
filter = QueryBuilders.termsQuery(name, values);
if (op.startsWith("NOT")) {
filter = QueryBuilders.boolQuery().mustNot(filter);
}
break;
case "IS NULL":
filter = QueryBuilders.boolQuery().mustNot(QueryBuilders.existsQuery(name));
break;
case "IS NOT NULL":
filter = QueryBuilders.existsQuery(name);
break;
case "LIKE":
case "ILIKE":
case "NOT LIKE":
case "NOT ILIKE":
query = makeLikeQuery(op, name, (String) value, hint);
if (op.startsWith("NOT")) {
filter = QueryBuilders.boolQuery().mustNot(query);
query = null;
}
break;
case "STARTSWITH":
filter = makeStartsWithQuery(name, value);
break;
default:
throw new UnsupportedOperationException("Operator: '" + op + "' is unknown");
}
return new QueryAndFilter(query, filter);
}
private static QueryBuilder makeHintFilter(String name, Object[] values, EsHint hint) {
QueryBuilder ret;
switch (hint.operator) {
case "geo_bounding_box":
if (values.length != 2) {
throw new IllegalArgumentException(String.format(
"Operator: %s requires 2 parameters: bottomLeft " + "and topRight point", hint.operator));
}
GeoPoint bottomLeft = parseGeoPointString((String) values[0]);
GeoPoint topRight = parseGeoPointString((String) values[1]);
ret = QueryBuilders.geoBoundingBoxQuery(name).bottomLeft(bottomLeft).topRight(topRight);
break;
case "geo_distance":
if (values.length != 2) {
throw new IllegalArgumentException(
String.format("Operator: %s requires 2 parameters: point and " + "distance", hint.operator));
}
GeoPoint center = parseGeoPointString((String) values[0]);
String distance = (String) values[1];
ret = QueryBuilders.geoDistanceQuery(name).point(center.lat(), center.lon()).distance(distance);
break;
case "geo_distance_range":
if (values.length != 3) {
throw new IllegalArgumentException(String.format(
"Operator: %s requires 3 parameters: point, " + "minimal and maximal distance", hint.operator));
}
center = parseGeoPointString((String) values[0]);
String from = (String) values[1];
String to = (String) values[2];
ret = QueryBuilders.geoDistanceRangeQuery(name).point(center.lat(), center.lon()).from(from).to(to);
break;
case "geo_hash_cell":
if (values.length != 2) {
throw new IllegalArgumentException(String.format(
"Operator: %s requires 2 parameters: point and " + "geohash precision", hint.operator));
}
center = parseGeoPointString((String) values[0]);
String precision = (String) values[1];
ret = QueryBuilders.geoHashCellQuery(name).point(center).precision(precision);
break;
case "geo_shape":
if (values.length != 4) {
throw new IllegalArgumentException(String.format(
"Operator: %s requires 4 parameters: shapeId, type, " + "index and path", hint.operator));
}
String shapeId = (String) values[0];
String shapeType = (String) values[1];
String shapeIndex = (String) values[2];
String shapePath = (String) values[3];
ret = QueryBuilders.geoShapeQuery(name, shapeId, shapeType, ShapeRelation.WITHIN)
.indexedShapeIndex(shapeIndex)
.indexedShapePath(shapePath);
break;
default:
throw new UnsupportedOperationException("Operator: '" + hint.operator + "' is unknown");
}
return ret;
}
private static GeoPoint parseGeoPointString(String value) {
try {
XContentBuilder content = JsonXContent.contentBuilder();
content.value(value);
XContentParser parser = JsonXContent.jsonXContent.createParser(content.bytes());
parser.nextToken();
return GeoUtils.parseGeoPoint(parser);
} catch (IOException e) {
throw new IllegalArgumentException("Invalid value for geopoint: " + e.getMessage());
}
}
private static QueryBuilder makeHintQuery(String name, Object value, EsHint hint) {
QueryBuilder ret;
switch (hint.operator) {
case "match":
MatchQueryBuilder matchQuery = QueryBuilders.matchQuery(name, value);
if (hint.analyzer != null) {
matchQuery.analyzer(hint.analyzer);
}
ret = matchQuery;
break;
case "match_phrase":
matchQuery = QueryBuilders.matchPhraseQuery(name, value);
if (hint.analyzer != null) {
matchQuery.analyzer(hint.analyzer);
}
ret = matchQuery;
break;
case "match_phrase_prefix":
matchQuery = QueryBuilders.matchPhrasePrefixQuery(name, value);
if (hint.analyzer != null) {
matchQuery.analyzer(hint.analyzer);
}
ret = matchQuery;
break;
case "multi_match":
// hint.index must be set
MultiMatchQueryBuilder multiMatchQuery = QueryBuilders.multiMatchQuery(value, hint.getIndex());
if (hint.analyzer != null) {
multiMatchQuery.analyzer(hint.analyzer);
}
ret = multiMatchQuery;
break;
case "regex":
ret = QueryBuilders.regexpQuery(name, (String) value);
break;
case "fuzzy":
ret = QueryBuilders.fuzzyQuery(name, (String) value);
break;
case "wildcard":
ret = QueryBuilders.wildcardQuery(name, (String) value);
break;
case "common":
CommonTermsQueryBuilder commonQuery = QueryBuilders.commonTermsQuery(name, value);
if (hint.analyzer != null) {
commonQuery.analyzer(hint.analyzer);
}
ret = commonQuery;
break;
case "query_string":
QueryStringQueryBuilder queryString = QueryBuilders.queryStringQuery((String) value);
if (hint.index != null) {
for (String index : hint.getIndex()) {
queryString.field(index);
}
} else {
queryString.defaultField(name);
}
if (hint.analyzer != null) {
queryString.analyzer(hint.analyzer);
}
ret = queryString;
break;
case "simple_query_string":
SimpleQueryStringBuilder querySimpleString = QueryBuilders.simpleQueryStringQuery((String) value);
if (hint.index != null) {
for (String index : hint.getIndex()) {
querySimpleString.field(index);
}
} else {
querySimpleString.field(name);
}
if (hint.analyzer != null) {
querySimpleString.analyzer(hint.analyzer);
}
ret = querySimpleString;
break;
default:
throw new UnsupportedOperationException("Operator: '" + hint.operator + "' is unknown");
}
return ret;
}
private static QueryBuilder makeStartsWithQuery(String name, Object value) {
QueryBuilder filter;
String indexName = name + ".children";
if ("/".equals(value)) {
// match all document with a path
filter = QueryBuilders.existsQuery(indexName);
} else {
String v = String.valueOf(value);
if (v.endsWith("/")) {
v = v.replaceAll("/$", "");
}
if (NXQL.ECM_PATH.equals(name)) {
// we don't want to return the parent when searching on ecm:path, see NXP-18955
filter = QueryBuilders.boolQuery().must(QueryBuilders.termQuery(indexName, v)).mustNot(
QueryBuilders.termQuery(name, value));
} else {
filter = QueryBuilders.termQuery(indexName, v);
}
}
return filter;
}
private static QueryBuilder makeAncestorIdFilter(String value, CoreSession session) {
String path;
if (session == null) {
return QueryBuilders.existsQuery("ancestorid-without-session");
} else {
try {
DocumentModel doc = session.getDocument(new IdRef(value));
path = doc.getPathAsString();
} catch (DocumentNotFoundException e) {
return QueryBuilders.existsQuery("ancestorid-not-found");
}
}
return makeStartsWithQuery(NXQL.ECM_PATH, path);
}
private static QueryBuilder makeLikeQuery(String op, String name, String value, EsHint hint) {
String fieldName = name;
if (op.contains("ILIKE")) {
// ILIKE will work only with a correct mapping
value = value.toLowerCase();
fieldName = name + ".lowercase";
}
if (hint != null && hint.index != null) {
fieldName = hint.index;
}
// convert the value to a wildcard query
String wildcard = likeToWildcard(value);
// use match phrase prefix when possible
if (StringUtils.countMatches(wildcard, "*") == 1 && wildcard.endsWith("*") && !wildcard.contains("?")
&& !wildcard.contains("\\")) {
MatchQueryBuilder query = QueryBuilders.matchPhrasePrefixQuery(fieldName, wildcard.replace("*", ""));
if (hint != null && hint.analyzer != null) {
query.analyzer(hint.analyzer);
}
return query;
}
return QueryBuilders.wildcardQuery(fieldName, wildcard);
}
/**
* Turns a NXQL LIKE pattern into a wildcard for WildcardQuery.
* <p>
* % and _ are standard wildcards, and \ escapes them.
*
* @since 7.4
*/
protected static String likeToWildcard(String like) {
StringBuilder wildcard = new StringBuilder();
char[] chars = like.toCharArray();
boolean escape = false;
for (int i = 0; i < chars.length; i++) {
char c = chars[i];
boolean escapeNext = false;
switch (c) {
case '?':
wildcard.append("\\?");
break;
case '*': // compat, * = % in NXQL (for some backends)
case '%':
if (escape) {
wildcard.append(c);
} else {
wildcard.append("*");
}
break;
case '_':
if (escape) {
wildcard.append(c);
} else {
wildcard.append("?");
}
break;
case '\\':
if (escape) {
wildcard.append("\\\\");
} else {
escapeNext = true;
}
break;
default:
wildcard.append(c);
break;
}
escape = escapeNext;
}
if (escape) {
// invalid string terminated by escape character, ignore
}
return wildcard.toString();
}
private static QueryBuilder makeFulltextQuery(String nxqlName, String value, EsHint hint) {
String name = nxqlName.replace(NXQL.ECM_FULLTEXT, "");
if (name.startsWith(".")) {
name = name.substring(1) + ".fulltext";
} else {
// map ecm:fulltext_someindex to default
name = FULLTEXT_FIELD;
}
String queryString = value;
SimpleQueryStringBuilder.Operator defaultOperator;
if (queryString.startsWith(SIMPLE_QUERY_PREFIX)) {
// elasticsearch-specific syntax
queryString = queryString.substring(SIMPLE_QUERY_PREFIX.length());
defaultOperator = SimpleQueryStringBuilder.Operator.OR;
} else {
queryString = translateFulltextQuery(queryString);
defaultOperator = SimpleQueryStringBuilder.Operator.AND;
}
String analyzer = (hint != null && hint.analyzer != null) ? hint.analyzer : "fulltext";
SimpleQueryStringBuilder query = QueryBuilders.simpleQueryStringQuery(queryString)
.defaultOperator(defaultOperator)
.analyzer(analyzer);
if (hint != null && hint.index != null) {
for (String index : hint.getIndex()) {
query.field(index);
}
} else {
query.field(name);
}
return query;
}
private static String getFieldName(String name, EsHint hint) {
if (hint != null && hint.index != null) {
return hint.index;
}
// compat
if (NXQL.ECM_ISVERSION_OLD.equals(name)) {
name = NXQL.ECM_ISVERSION;
}
// complex field
name = name.replace("/*", "");
name = name.replace("/", ".");
return name;
}
public static List<SortInfo> getSortInfo(String nxql) {
final List<SortInfo> sortInfos = new ArrayList<>();
SQLQuery nxqlQuery = getSqlQuery(nxql);
nxqlQuery.accept(new DefaultQueryVisitor() {
private static final long serialVersionUID = 1L;
@Override
public void visitOrderByExpr(OrderByExpr node) {
String name = getFieldName(node.reference.name, null);
if (NXQL.ECM_FULLTEXT_SCORE.equals(name)) {
name = "_score";
}
sortInfos.add(new SortInfo(name, !node.isDescending));
}
});
return sortInfos;
}
public static Map<String, Type> getSelectClauseFields(String nxql) {
final Map<String, Type> fieldsAndTypes = new LinkedHashMap<>();
SQLQuery nxqlQuery = getSqlQuery(nxql);
nxqlQuery.accept(new DefaultQueryVisitor() {
private static final long serialVersionUID = 1L;
@Override
public void visitSelectClause(SelectClause selectClause) {
SchemaManager schemaManager = Framework.getLocalService(SchemaManager.class);
for (int i = 0; i < selectClause.getSelectList().size(); i++) {
Operand op = selectClause.get(i);
if (!(op instanceof Reference)) {
// ignore it
continue;
}
String name = ((Reference) op).name;
Field field = schemaManager.getField(name);
fieldsAndTypes.put(name, field == null ? null : field.getType());
}
}
});
return fieldsAndTypes;
}
/**
* Translates from Nuxeo syntax to Elasticsearch simple_query_string syntax.
*/
public static String translateFulltextQuery(String query) {
// The AND operator does not exist in NXQL it is the default operator
return query.replace(" OR ", " | ").replace(" or ", " | ");
}
/**
* Class to hold both a query and a filter
*/
public static class QueryAndFilter {
public final QueryBuilder query;
public final QueryBuilder filter;
public QueryAndFilter(QueryBuilder query, QueryBuilder filter) {
this.query = query;
this.filter = filter;
}
}
public static class ExpressionBuilder {
public final String operator;
public QueryBuilder query;
public ExpressionBuilder(final String op) {
this.operator = op;
this.query = null;
}
public void add(final QueryAndFilter qf) {
if (qf != null) {
add(qf.query, qf.filter);
}
}
public void add(QueryBuilder q) {
add(q, null);
}
public void add(final QueryBuilder q, final QueryBuilder f) {
if (q == null && f == null) {
return;
}
QueryBuilder inputQuery = q;
if (inputQuery == null) {
inputQuery = QueryBuilders.constantScoreQuery(f);
}
if (operator == null) {
// first level expression
query = inputQuery;
} else {
// boolean query
if (query == null) {
query = QueryBuilders.boolQuery();
}
BoolQueryBuilder boolQuery = (BoolQueryBuilder) query;
if ("AND".equals(operator)) {
boolQuery.must(inputQuery);
} else if ("OR".equals(operator)) {
boolQuery.should(inputQuery);
} else if ("NOT".equals(operator)) {
boolQuery.mustNot(inputQuery);
}
}
}
public void merge(ExpressionBuilder expr) {
if ((expr.operator != null) && expr.operator.equals(operator) && (query == null)) {
query = expr.query;
} else {
add(new QueryAndFilter(expr.query, null));
}
}
public QueryBuilder get() {
if (query == null) {
return QueryBuilders.matchAllQuery();
}
return query;
}
@Override
public String toString() {
return query.toString();
}
}
}