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 * Tiry 018 * bdelbosc 019 */ 020package org.nuxeo.elasticsearch.query; 021 022import static org.elasticsearch.common.xcontent.DeprecationHandler.THROW_UNSUPPORTED_OPERATION; 023import static org.nuxeo.elasticsearch.ElasticSearchConstants.DOC_TYPE; 024import static org.nuxeo.elasticsearch.ElasticSearchConstants.ES_SCORE_FIELD; 025import static org.nuxeo.elasticsearch.ElasticSearchConstants.FULLTEXT_FIELD; 026 027import java.io.ByteArrayOutputStream; 028import java.io.IOException; 029import java.io.StringReader; 030import java.util.ArrayList; 031import java.util.Collection; 032import java.util.Iterator; 033import java.util.LinkedHashMap; 034import java.util.LinkedList; 035import java.util.List; 036import java.util.Map; 037import java.util.Set; 038 039import org.apache.commons.lang3.StringUtils; 040import org.apache.commons.logging.Log; 041import org.apache.commons.logging.LogFactory; 042import org.elasticsearch.common.geo.GeoPoint; 043import org.elasticsearch.common.geo.GeoUtils; 044import org.elasticsearch.common.geo.ShapeRelation; 045import org.elasticsearch.common.xcontent.NamedXContentRegistry; 046import org.elasticsearch.common.xcontent.XContentBuilder; 047import org.elasticsearch.common.xcontent.XContentParser; 048import org.elasticsearch.common.xcontent.json.JsonXContent; 049import org.elasticsearch.index.query.BoolQueryBuilder; 050import org.elasticsearch.index.query.CommonTermsQueryBuilder; 051import org.elasticsearch.index.query.MatchPhrasePrefixQueryBuilder; 052import org.elasticsearch.index.query.MatchPhraseQueryBuilder; 053import org.elasticsearch.index.query.MatchQueryBuilder; 054import org.elasticsearch.index.query.MoreLikeThisQueryBuilder; 055import org.elasticsearch.index.query.MultiMatchQueryBuilder; 056import org.elasticsearch.index.query.QueryBuilder; 057import org.elasticsearch.index.query.QueryBuilders; 058import org.elasticsearch.index.query.QueryStringQueryBuilder; 059import org.elasticsearch.index.query.SimpleQueryStringBuilder; 060import org.nuxeo.ecm.core.NXCore; 061import org.nuxeo.ecm.core.api.CoreSession; 062import org.nuxeo.ecm.core.api.DocumentModel; 063import org.nuxeo.ecm.core.api.DocumentNotFoundException; 064import org.nuxeo.ecm.core.api.IdRef; 065import org.nuxeo.ecm.core.api.LifeCycleConstants; 066import org.nuxeo.ecm.core.api.SortInfo; 067import org.nuxeo.ecm.core.api.repository.RepositoryManager; 068import org.nuxeo.ecm.core.api.trash.TrashService.Feature; 069import org.nuxeo.ecm.core.query.QueryParseException; 070import org.nuxeo.ecm.core.query.sql.NXQL; 071import org.nuxeo.ecm.core.query.sql.SQLQueryParser; 072import org.nuxeo.ecm.core.query.sql.model.DefaultQueryVisitor; 073import org.nuxeo.ecm.core.query.sql.model.EsHint; 074import org.nuxeo.ecm.core.query.sql.model.Expression; 075import org.nuxeo.ecm.core.query.sql.model.FromClause; 076import org.nuxeo.ecm.core.query.sql.model.FromList; 077import org.nuxeo.ecm.core.query.sql.model.Literal; 078import org.nuxeo.ecm.core.query.sql.model.LiteralList; 079import org.nuxeo.ecm.core.query.sql.model.MultiExpression; 080import org.nuxeo.ecm.core.query.sql.model.Operand; 081import org.nuxeo.ecm.core.query.sql.model.Operator; 082import org.nuxeo.ecm.core.query.sql.model.OrderByExpr; 083import org.nuxeo.ecm.core.query.sql.model.Reference; 084import org.nuxeo.ecm.core.query.sql.model.SQLQuery; 085import org.nuxeo.ecm.core.query.sql.model.SelectClause; 086import org.nuxeo.ecm.core.schema.SchemaManager; 087import org.nuxeo.ecm.core.schema.types.Field; 088import org.nuxeo.ecm.core.schema.types.Type; 089import org.nuxeo.ecm.core.schema.types.primitives.BooleanType; 090import org.nuxeo.ecm.core.storage.sql.jdbc.NXQLQueryMaker; 091import org.nuxeo.ecm.core.api.trash.TrashService; 092import org.nuxeo.elasticsearch.api.ElasticSearchAdmin; 093import org.nuxeo.runtime.api.Framework; 094 095/** 096 * Helper class that holds the conversion logic. Conversion is based on the existing NXQL Parser, we are just using a 097 * visitor to build the ES request. 098 */ 099public final class NxqlQueryConverter { 100 private static final Log log = LogFactory.getLog(NxqlQueryConverter.class); 101 102 private static final String SELECT_ALL = "SELECT * FROM Document"; 103 104 private static final String SELECT_ALL_WHERE = "SELECT * FROM Document WHERE "; 105 106 private static final String SIMPLE_QUERY_PREFIX = "es: "; 107 108 protected static final int MORE_LIKE_THIS_MIN_TERM_FREQ = 1; 109 110 protected static final int MORE_LIKE_THIS_MIN_DOC_FREQ = 3; 111 112 protected static final int MORE_LIKE_THIS_MAX_QUERY_TERMS = 12; 113 114 private NxqlQueryConverter() { 115 } 116 117 public static QueryBuilder toESQueryBuilder(final String nxql) { 118 return toESQueryBuilder(nxql, null); 119 } 120 121 public static QueryBuilder toESQueryBuilder(final String nxql, final CoreSession session) { 122 final LinkedList<ExpressionBuilder> builders = new LinkedList<>(); 123 SQLQuery nxqlQuery = getSqlQuery(nxql); 124 if (session != null) { 125 nxqlQuery = addSecurityPolicy(session, nxqlQuery); 126 } 127 final ExpressionBuilder ret = new ExpressionBuilder(null); 128 builders.add(ret); 129 final ArrayList<String> fromList = new ArrayList<>(); 130 nxqlQuery.accept(new DefaultQueryVisitor() { 131 132 private static final long serialVersionUID = 1L; 133 134 @Override 135 public void visitFromClause(FromClause node) { 136 FromList elements = node.elements; 137 SchemaManager schemaManager = Framework.getService(SchemaManager.class); 138 139 for (String type : elements.values()) { 140 if (NXQLQueryMaker.TYPE_DOCUMENT.equalsIgnoreCase(type)) { 141 // From Document means all doc types 142 fromList.clear(); 143 return; 144 } 145 Set<String> types = schemaManager.getDocumentTypeNamesExtending(type); 146 if (types != null) { 147 fromList.addAll(types); 148 } 149 } 150 } 151 152 @Override 153 public void visitMultiExpression(MultiExpression node) { 154 for (Iterator<Operand> it = node.values.iterator(); it.hasNext();) { 155 it.next().accept(this); 156 if (it.hasNext()) { 157 node.operator.accept(this); 158 } 159 } 160 } 161 162 @Override 163 public void visitSelectClause(SelectClause node) { 164 // NOP 165 } 166 167 @Override 168 public void visitExpression(Expression node) { 169 Operator op = node.operator; 170 if (op == Operator.AND || op == Operator.OR || op == Operator.NOT) { 171 builders.add(new ExpressionBuilder(op.toString())); 172 super.visitExpression(node); 173 ExpressionBuilder expr = builders.removeLast(); 174 if (!builders.isEmpty()) { 175 builders.getLast().merge(expr); 176 } 177 } else { 178 Reference ref = node.lvalue instanceof Reference ? (Reference) node.lvalue : null; 179 String name = ref != null ? ref.name : node.lvalue.toString(); 180 String value = null; 181 if (node.rvalue instanceof Literal) { 182 value = ((Literal) node.rvalue).asString(); 183 } else if (node.rvalue != null) { 184 value = node.rvalue.toString(); 185 } 186 Object[] values = null; 187 if (node.rvalue instanceof LiteralList) { 188 LiteralList items = (LiteralList) node.rvalue; 189 values = new Object[items.size()]; 190 int i = 0; 191 for (Literal item : items) { 192 values[i++] = item.asString(); 193 } 194 } 195 // add expression to the last builder 196 EsHint hint = (ref != null) ? ref.esHint : null; 197 builders.getLast() 198 .add(makeQueryFromSimpleExpression(op.toString(), name, value, values, hint, session)); 199 } 200 } 201 }); 202 QueryBuilder queryBuilder = ret.get(); 203 if (!fromList.isEmpty()) { 204 return QueryBuilders.boolQuery().must(queryBuilder).filter(makeQueryFromSimpleExpression("IN", 205 NXQL.ECM_PRIMARYTYPE, null, fromList.toArray(), null, null).filter); 206 } 207 return queryBuilder; 208 } 209 210 protected static SQLQuery getSqlQuery(String nxql) { 211 String query = completeQueryWithSelect(nxql); 212 SQLQuery nxqlQuery; 213 try { 214 nxqlQuery = SQLQueryParser.parse(new StringReader(query)); 215 } catch (QueryParseException e) { 216 if (log.isDebugEnabled()) { 217 log.debug(e.getMessage() + " for query:\n" + query); 218 } 219 throw e; 220 } 221 return nxqlQuery; 222 } 223 224 protected static SQLQuery addSecurityPolicy(CoreSession session, SQLQuery query) { 225 Collection<SQLQuery.Transformer> transformers = NXCore.getSecurityService().getPoliciesQueryTransformers( 226 session.getRepositoryName()); 227 for (SQLQuery.Transformer trans : transformers) { 228 query = trans.transform(session.getPrincipal(), query); 229 } 230 return query; 231 } 232 233 protected static String completeQueryWithSelect(String nxql) { 234 String query = (nxql == null) ? "" : nxql.trim(); 235 if (query.isEmpty()) { 236 query = SELECT_ALL; 237 } else if (!query.toLowerCase().startsWith("select ")) { 238 query = SELECT_ALL_WHERE + nxql; 239 } 240 return query; 241 } 242 243 public static QueryAndFilter makeQueryFromSimpleExpression(String op, String nxqlName, Object value, 244 Object[] values, EsHint hint, CoreSession session) { 245 QueryBuilder query = null; 246 QueryBuilder filter = null; 247 String name = getFieldName(nxqlName, hint); 248 if (hint != null && hint.operator != null) { 249 if (hint.operator.startsWith("geo")) { 250 filter = makeHintFilter(name, values, hint); 251 } else { 252 query = makeHintQuery(name, value, hint); 253 } 254 } else if (nxqlName.startsWith(NXQL.ECM_FULLTEXT) && ("=".equals(op) || "!=".equals(op) || "<>".equals(op) 255 || "LIKE".equals(op) || "NOT LIKE".equals(op))) { 256 query = makeFulltextQuery(nxqlName, (String) value, hint); 257 if ("!=".equals(op) || "<>".equals(op) || "NOT LIKE".equals(op)) { 258 filter = QueryBuilders.boolQuery().mustNot(query); 259 query = null; 260 } 261 } else if (nxqlName.startsWith(NXQL.ECM_ANCESTORID)) { 262 filter = makeAncestorIdFilter((String) value, session); 263 if ("!=".equals(op) || "<>".equals(op)) { 264 filter = QueryBuilders.boolQuery().mustNot(filter); 265 } 266 } else if (nxqlName.equals(NXQL.ECM_ISTRASHED)) { 267 filter = makeTrashedFilter(op, name, (String) value); 268 269 } else 270 switch (op) { 271 case "=": 272 filter = QueryBuilders.termQuery(name, checkBoolValue(nxqlName, value)); 273 break; 274 case "<>": 275 case "!=": 276 filter = QueryBuilders.boolQuery().mustNot(QueryBuilders.termQuery(name, checkBoolValue(nxqlName, value))); 277 break; 278 case ">": 279 filter = QueryBuilders.rangeQuery(name).gt(value); 280 break; 281 case "<": 282 filter = QueryBuilders.rangeQuery(name).lt(value); 283 break; 284 case ">=": 285 filter = QueryBuilders.rangeQuery(name).gte(value); 286 break; 287 case "<=": 288 filter = QueryBuilders.rangeQuery(name).lte(value); 289 break; 290 case "BETWEEN": 291 case "NOT BETWEEN": 292 filter = QueryBuilders.rangeQuery(name).from(values[0]).to(values[1]); 293 if (op.startsWith("NOT")) { 294 filter = QueryBuilders.boolQuery().mustNot(filter); 295 } 296 break; 297 case "IN": 298 case "NOT IN": 299 filter = QueryBuilders.termsQuery(name, values); 300 if (op.startsWith("NOT")) { 301 filter = QueryBuilders.boolQuery().mustNot(filter); 302 } 303 break; 304 case "IS NULL": 305 filter = QueryBuilders.boolQuery().mustNot(QueryBuilders.existsQuery(name)); 306 break; 307 case "IS NOT NULL": 308 filter = QueryBuilders.existsQuery(name); 309 break; 310 case "LIKE": 311 case "ILIKE": 312 case "NOT LIKE": 313 case "NOT ILIKE": 314 query = makeLikeQuery(op, name, (String) value, hint); 315 if (op.startsWith("NOT")) { 316 filter = QueryBuilders.boolQuery().mustNot(query); 317 query = null; 318 } 319 break; 320 case "STARTSWITH": 321 filter = makeStartsWithQuery(name, value); 322 break; 323 default: 324 throw new UnsupportedOperationException("Operator: '" + op + "' is unknown"); 325 } 326 return new QueryAndFilter(query, filter); 327 } 328 329 protected static Object checkBoolValue(String nxqlName, Object value) { 330 if (! "0".equals(value) && !"1".equals(value)) { 331 return value; 332 } 333 switch (nxqlName) { 334 case NXQL.ECM_ISPROXY: 335 case NXQL.ECM_ISCHECKEDIN: 336 case NXQL.ECM_ISTRASHED: 337 case NXQL.ECM_ISVERSION: 338 case NXQL.ECM_ISVERSION_OLD: 339 case NXQL.ECM_ISLATESTMAJORVERSION: 340 case NXQL.ECM_ISLATESTVERSION: 341 break; 342 default: 343 SchemaManager schemaManager = Framework.getService(SchemaManager.class); 344 Field field = schemaManager.getField(nxqlName); 345 if (field == null || !BooleanType.ID.equals(field.getType().getName())) { 346 return value; 347 } 348 } 349 return "0".equals(value) ? "false": "true"; 350 } 351 352 protected static QueryBuilder makeTrashedFilter(String op, String name, String value) { 353 boolean equalsDeleted; 354 switch (op) { 355 case "=": 356 equalsDeleted = true; 357 break; 358 case "<>": 359 case "!=": 360 equalsDeleted = false; 361 break; 362 default: 363 throw new IllegalArgumentException(NXQL.ECM_ISTRASHED + " requires = or <> operator"); 364 } 365 if ("0".equals(value)) { 366 equalsDeleted = !equalsDeleted; 367 } else if ("1".equals(value)) { 368 // equalsDeleted unchanged 369 } else { 370 throw new IllegalArgumentException(NXQL.ECM_ISTRASHED + " requires literal 0 or 1 as right argument"); 371 } 372 TrashService trashService = Framework.getService(TrashService.class); 373 QueryBuilder filter = null; 374 if (trashService.hasFeature(Feature.TRASHED_STATE_IS_DEDUCED_FROM_LIFECYCLE)) { 375 filter = QueryBuilders.termQuery(NXQL.ECM_LIFECYCLESTATE, LifeCycleConstants.DELETED_STATE); 376 } else if (trashService.hasFeature(Feature.TRASHED_STATE_IN_MIGRATION)) { 377 filter = QueryBuilders.boolQuery() 378 .should(QueryBuilders.termQuery(NXQL.ECM_LIFECYCLESTATE, 379 LifeCycleConstants.DELETED_STATE)) 380 .should(QueryBuilders.termQuery(name, true)); 381 } else if (trashService.hasFeature(Feature.TRASHED_STATE_IS_DEDICATED_PROPERTY)) { 382 filter = QueryBuilders.termQuery(name, true); 383 } 384 if (!equalsDeleted) { 385 filter = QueryBuilders.boolQuery().mustNot(filter); 386 } 387 return filter; 388 } 389 390 private static QueryBuilder makeHintFilter(String name, Object[] values, EsHint hint) { 391 QueryBuilder ret; 392 switch (hint.operator) { 393 case "geo_bounding_box": 394 if (values.length != 2) { 395 throw new IllegalArgumentException(String.format( 396 "Operator: %s requires 2 parameters: bottomLeft " + "and topRight point", hint.operator)); 397 } 398 GeoPoint bottomLeft = parseGeoPointString((String) values[0]); 399 GeoPoint topRight = parseGeoPointString((String) values[1]); 400 ret = QueryBuilders.geoBoundingBoxQuery(name).setCornersOGC(bottomLeft, topRight); 401 break; 402 case "geo_distance": 403 if (values.length != 2) { 404 throw new IllegalArgumentException( 405 String.format("Operator: %s requires 2 parameters: point and " + "distance", hint.operator)); 406 } 407 GeoPoint center = parseGeoPointString((String) values[0]); 408 String distance = (String) values[1]; 409 ret = QueryBuilders.geoDistanceQuery(name).point(center.lat(), center.lon()).distance(distance); 410 break; 411 case "geo_distance_range": 412 throw new UnsupportedOperationException("Operator: '" + hint.operator + "' is deprecated since Elasticsearch 6.1"); 413 case "geo_hash_cell": 414 throw new UnsupportedOperationException("Operator: '" + hint.operator + "' is deprecated since Elasticsearch 6.2"); 415 case "geo_shape": 416 if (values.length != 4) { 417 throw new IllegalArgumentException(String.format( 418 "Operator: %s requires 4 parameters: shapeId, type, " + "index and path", hint.operator)); 419 } 420 String shapeId = (String) values[0]; 421 String shapeType = (String) values[1]; 422 String shapeIndex = (String) values[2]; 423 String shapePath = (String) values[3]; 424 425 ret = QueryBuilders.geoShapeQuery(name, shapeId, shapeType) 426 .relation(ShapeRelation.WITHIN) 427 .indexedShapeIndex(shapeIndex) 428 .indexedShapePath(shapePath); 429 break; 430 default: 431 throw new UnsupportedOperationException("Operator: '" + hint.operator + "' is unknown"); 432 } 433 return ret; 434 435 } 436 437 private static GeoPoint parseGeoPointString(String value) { 438 try { 439 XContentBuilder content = JsonXContent.contentBuilder(); 440 content.value(value); 441 content.flush(); 442 content.close(); 443 try (XContentParser parser = JsonXContent.jsonXContent.createParser(NamedXContentRegistry.EMPTY, 444 THROW_UNSUPPORTED_OPERATION, ((ByteArrayOutputStream) content.getOutputStream()).toByteArray())) { 445 parser.nextToken(); 446 return GeoUtils.parseGeoPoint(parser); 447 } 448 } catch (IOException e) { 449 throw new IllegalArgumentException("Invalid value for geopoint: " + e.getMessage()); 450 } 451 } 452 453 private static QueryBuilder makeHintQuery(String name, Object value, EsHint hint) { 454 QueryBuilder ret; 455 switch (hint.operator) { 456 case "match": 457 MatchQueryBuilder matchQuery = QueryBuilders.matchQuery(name, value); 458 if (hint.analyzer != null) { 459 matchQuery.analyzer(hint.analyzer); 460 } 461 ret = matchQuery; 462 break; 463 case "match_phrase": 464 MatchPhraseQueryBuilder matchPhraseQuery = QueryBuilders.matchPhraseQuery(name, value); 465 if (hint.analyzer != null) { 466 matchPhraseQuery.analyzer(hint.analyzer); 467 } 468 ret = matchPhraseQuery; 469 break; 470 case "match_phrase_prefix": 471 MatchPhrasePrefixQueryBuilder matchPhrasePrefixQuery = QueryBuilders.matchPhrasePrefixQuery(name, value); 472 if (hint.analyzer != null) { 473 matchPhrasePrefixQuery.analyzer(hint.analyzer); 474 } 475 ret = matchPhrasePrefixQuery; 476 break; 477 case "multi_match": 478 // hint.index must be set 479 MultiMatchQueryBuilder multiMatchQuery = QueryBuilders.multiMatchQuery(value, hint.getIndex()); 480 if (hint.analyzer != null) { 481 multiMatchQuery.analyzer(hint.analyzer); 482 } 483 ret = multiMatchQuery; 484 break; 485 case "regex": 486 ret = QueryBuilders.regexpQuery(name, (String) value); 487 break; 488 case "fuzzy": 489 ret = QueryBuilders.fuzzyQuery(name, (String) value); 490 break; 491 case "wildcard": 492 ret = QueryBuilders.wildcardQuery(name, (String) value); 493 break; 494 case "common": 495 CommonTermsQueryBuilder commonQuery = QueryBuilders.commonTermsQuery(name, value); 496 if (hint.analyzer != null) { 497 commonQuery.analyzer(hint.analyzer); 498 } 499 ret = commonQuery; 500 break; 501 case "query_string": 502 QueryStringQueryBuilder queryString = QueryBuilders.queryStringQuery((String) value); 503 if (hint.index != null) { 504 for (String index : hint.getIndex()) { 505 queryString.field(index); 506 } 507 } else { 508 queryString.defaultField(name); 509 } 510 if (hint.analyzer != null) { 511 queryString.analyzer(hint.analyzer); 512 } 513 ret = queryString; 514 break; 515 case "simple_query_string": 516 SimpleQueryStringBuilder querySimpleString = QueryBuilders.simpleQueryStringQuery((String) value); 517 if (hint.index != null) { 518 for (String index : hint.getIndex()) { 519 querySimpleString.field(index); 520 } 521 } else { 522 querySimpleString.field(name); 523 } 524 if (hint.analyzer != null) { 525 querySimpleString.analyzer(hint.analyzer); 526 } 527 ret = querySimpleString; 528 break; 529 case "more_like_this": 530 String[] fields = hint.getIndex() != null ? hint.getIndex() : new String[] { name }; 531 MoreLikeThisQueryBuilder moreLikeThisBuilder = QueryBuilders.moreLikeThisQuery(fields, null, 532 getItems(value)); 533 if (hint.analyzer != null) { 534 moreLikeThisBuilder.analyzer(hint.analyzer); 535 } 536 moreLikeThisBuilder.minDocFreq(MORE_LIKE_THIS_MIN_DOC_FREQ); 537 moreLikeThisBuilder.minTermFreq(MORE_LIKE_THIS_MIN_TERM_FREQ); 538 moreLikeThisBuilder.maxQueryTerms(MORE_LIKE_THIS_MAX_QUERY_TERMS); 539 ret = moreLikeThisBuilder; 540 break; 541 default: 542 throw new UnsupportedOperationException("Operator: '" + hint.operator + "' is unknown"); 543 } 544 return ret; 545 } 546 547 protected static MoreLikeThisQueryBuilder.Item[] getItems(Object value) { 548 RepositoryManager rm = Framework.getService(RepositoryManager.class); 549 String repo = rm.getDefaultRepository().getName(); 550 ElasticSearchAdmin esa = Framework.getService(ElasticSearchAdmin.class); 551 String esIndex = esa.getIndexNameForRepository(repo); 552 String[] values; 553 if (value instanceof Object[]) { 554 values = (String[]) value; 555 } else { 556 values = new String[] { (String) value }; 557 } 558 MoreLikeThisQueryBuilder.Item[] ret = new MoreLikeThisQueryBuilder.Item[values.length]; 559 for (int i = 0; i < values.length; i++) { 560 ret[i] = new MoreLikeThisQueryBuilder.Item(esIndex, DOC_TYPE, values[i]); 561 } 562 return ret; 563 } 564 565 public static QueryBuilder makeStartsWithQuery(String name, Object value) { 566 QueryBuilder filter; 567 String indexName = name + ".children"; 568 if ("/".equals(value)) { 569 // match all document with a path 570 filter = QueryBuilders.existsQuery(indexName); 571 } else { 572 String v = String.valueOf(value); 573 if (v.endsWith("/")) { 574 v = v.replaceAll("/$", ""); 575 } 576 if (NXQL.ECM_PATH.equals(name)) { 577 // we don't want to return the parent when searching on ecm:path, see NXP-18955 578 filter = QueryBuilders.boolQuery().must(QueryBuilders.termQuery(indexName, v)).mustNot( 579 QueryBuilders.termQuery(name, value)); 580 } else { 581 filter = QueryBuilders.termQuery(indexName, v); 582 } 583 } 584 return filter; 585 } 586 587 private static QueryBuilder makeAncestorIdFilter(String value, CoreSession session) { 588 String path; 589 if (session == null) { 590 return QueryBuilders.existsQuery("ancestorid-without-session"); 591 } else { 592 try { 593 DocumentModel doc = session.getDocument(new IdRef(value)); 594 path = doc.getPathAsString(); 595 } catch (DocumentNotFoundException e) { 596 return QueryBuilders.existsQuery("ancestorid-not-found"); 597 } 598 } 599 return makeStartsWithQuery(NXQL.ECM_PATH, path); 600 } 601 602 private static QueryBuilder makeLikeQuery(String op, String name, String value, EsHint hint) { 603 String fieldName = name; 604 if (op.contains("ILIKE")) { 605 // ILIKE will work only with a correct mapping 606 value = value.toLowerCase(); 607 fieldName = name + ".lowercase"; 608 } 609 if (hint != null && hint.index != null) { 610 fieldName = hint.index; 611 } 612 // convert the value to a wildcard query 613 String wildcard = likeToWildcard(value); 614 // use match phrase prefix when possible 615 if (StringUtils.countMatches(wildcard, "*") == 1 && wildcard.endsWith("*") && !wildcard.contains("?") 616 && !wildcard.contains("\\")) { 617 MatchPhrasePrefixQueryBuilder query = QueryBuilders.matchPhrasePrefixQuery(fieldName, 618 wildcard.replace("*", "")); 619 if (hint != null && hint.analyzer != null) { 620 query.analyzer(hint.analyzer); 621 } 622 return query; 623 } 624 return QueryBuilders.wildcardQuery(fieldName, wildcard); 625 } 626 627 /** 628 * Turns a NXQL LIKE pattern into a wildcard for WildcardQuery. 629 * <p> 630 * % and _ are standard wildcards, and \ escapes them. 631 * 632 * @since 7.4 633 */ 634 protected static String likeToWildcard(String like) { 635 StringBuilder wildcard = new StringBuilder(); 636 char[] chars = like.toCharArray(); 637 boolean escape = false; 638 for (char c : chars) { 639 boolean escapeNext = false; 640 switch (c) { 641 case '?': 642 wildcard.append("\\?"); 643 break; 644 case '*': // compat, * = % in NXQL (for some backends) 645 case '%': 646 if (escape) { 647 wildcard.append(c); 648 } else { 649 wildcard.append("*"); 650 } 651 break; 652 case '_': 653 if (escape) { 654 wildcard.append(c); 655 } else { 656 wildcard.append("?"); 657 } 658 break; 659 case '\\': 660 if (escape) { 661 wildcard.append("\\\\"); 662 } else { 663 escapeNext = true; 664 } 665 break; 666 default: 667 wildcard.append(c); 668 break; 669 } 670 escape = escapeNext; 671 } 672 if (escape) { 673 // invalid string terminated by escape character, ignore 674 } 675 return wildcard.toString(); 676 } 677 678 private static QueryBuilder makeFulltextQuery(String nxqlName, String value, EsHint hint) { 679 String name = nxqlName.replace(NXQL.ECM_FULLTEXT, ""); 680 if (name.startsWith(".")) { 681 name = name.substring(1) + ".fulltext"; 682 } else { 683 // map ecm:fulltext_someindex to default 684 name = FULLTEXT_FIELD; 685 } 686 String queryString = value; 687 org.elasticsearch.index.query.Operator defaultOperator; 688 if (queryString.startsWith(SIMPLE_QUERY_PREFIX)) { 689 // elasticsearch-specific syntax 690 queryString = queryString.substring(SIMPLE_QUERY_PREFIX.length()); 691 defaultOperator = org.elasticsearch.index.query.Operator.OR; 692 } else { 693 queryString = translateFulltextQuery(queryString); 694 defaultOperator = org.elasticsearch.index.query.Operator.AND; 695 } 696 String analyzer = (hint != null && hint.analyzer != null) ? hint.analyzer : "fulltext"; 697 SimpleQueryStringBuilder query = QueryBuilders.simpleQueryStringQuery(queryString) 698 .defaultOperator(defaultOperator) 699 .analyzer(analyzer); 700 if (hint != null && hint.index != null) { 701 for (String index : hint.getIndex()) { 702 query.field(index); 703 } 704 } else { 705 query.field(name); 706 } 707 return query; 708 } 709 710 private static String getFieldName(String name, EsHint hint) { 711 if (hint != null && hint.index != null) { 712 return hint.index; 713 } 714 // compat 715 if (NXQL.ECM_ISVERSION_OLD.equals(name)) { 716 name = NXQL.ECM_ISVERSION; 717 } 718 // complex field 719 name = name.replace("/*", ""); 720 name = name.replace("/", "."); 721 return name; 722 } 723 724 public static List<SortInfo> getSortInfo(String nxql) { 725 final List<SortInfo> sortInfos = new ArrayList<>(); 726 SQLQuery nxqlQuery = getSqlQuery(nxql); 727 nxqlQuery.accept(new DefaultQueryVisitor() { 728 729 private static final long serialVersionUID = 1L; 730 731 @Override 732 public void visitOrderByExpr(OrderByExpr node) { 733 String name = getFieldName(node.reference.name, null); 734 if (NXQL.ECM_FULLTEXT_SCORE.equals(name)) { 735 name = ES_SCORE_FIELD; 736 } 737 sortInfos.add(new SortInfo(name, !node.isDescending)); 738 } 739 }); 740 return sortInfos; 741 } 742 743 public static Map<String, Type> getSelectClauseFields(String nxql) { 744 final Map<String, Type> fieldsAndTypes = new LinkedHashMap<>(); 745 SQLQuery nxqlQuery = getSqlQuery(nxql); 746 nxqlQuery.accept(new DefaultQueryVisitor() { 747 748 private static final long serialVersionUID = 1L; 749 750 @Override 751 public void visitSelectClause(SelectClause selectClause) { 752 SchemaManager schemaManager = Framework.getService(SchemaManager.class); 753 for (int i = 0; i < selectClause.getSelectList().size(); i++) { 754 Operand op = selectClause.get(i); 755 if (!(op instanceof Reference)) { 756 // ignore it 757 continue; 758 } 759 String name = ((Reference) op).name; 760 Field field = schemaManager.getField(name); 761 fieldsAndTypes.put(name, field == null ? null : field.getType()); 762 } 763 } 764 }); 765 return fieldsAndTypes; 766 } 767 768 /** 769 * Translates from Nuxeo syntax to Elasticsearch simple_query_string syntax. 770 */ 771 public static String translateFulltextQuery(String query) { 772 // The AND operator does not exist in NXQL it is the default operator 773 return query.replace(" OR ", " | ").replace(" or ", " | "); 774 } 775 776 /** 777 * Class to hold both a query and a filter 778 */ 779 public static class QueryAndFilter { 780 781 public final QueryBuilder query; 782 783 public final QueryBuilder filter; 784 785 public QueryAndFilter(QueryBuilder query, QueryBuilder filter) { 786 this.query = query; 787 this.filter = filter; 788 } 789 } 790 791 public static class ExpressionBuilder { 792 793 public final String operator; 794 795 public QueryBuilder query; 796 797 public ExpressionBuilder(final String op) { 798 this.operator = op; 799 this.query = null; 800 } 801 802 public void add(final QueryAndFilter qf) { 803 if (qf != null) { 804 add(qf.query, qf.filter); 805 } 806 } 807 808 public void add(QueryBuilder q) { 809 add(q, null); 810 } 811 812 public void add(final QueryBuilder q, final QueryBuilder f) { 813 if (q == null && f == null) { 814 return; 815 } 816 QueryBuilder inputQuery = q; 817 if (inputQuery == null) { 818 inputQuery = QueryBuilders.constantScoreQuery(f); 819 } 820 if (operator == null) { 821 // first level expression 822 query = inputQuery; 823 } else { 824 // boolean query 825 if (query == null) { 826 query = QueryBuilders.boolQuery(); 827 } 828 BoolQueryBuilder boolQuery = (BoolQueryBuilder) query; 829 if ("AND".equals(operator)) { 830 boolQuery.must(inputQuery); 831 } else if ("OR".equals(operator)) { 832 boolQuery.should(inputQuery); 833 } else if ("NOT".equals(operator)) { 834 boolQuery.mustNot(inputQuery); 835 } 836 } 837 } 838 839 public void merge(ExpressionBuilder expr) { 840 if ((expr.operator != null) && expr.operator.equals(operator) && (query == null)) { 841 query = expr.query; 842 } else { 843 add(new QueryAndFilter(expr.query, null)); 844 } 845 } 846 847 public QueryBuilder get() { 848 if (query == null) { 849 return QueryBuilders.matchAllQuery(); 850 } 851 return query; 852 } 853 854 @Override 855 public String toString() { 856 return query.toString(); 857 } 858 859 } 860}