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