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