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; 020 021import static java.lang.Boolean.FALSE; 022import static java.lang.Boolean.TRUE; 023import static org.nuxeo.ecm.core.api.trash.TrashService.Feature.TRASHED_STATE_IN_MIGRATION; 024import static org.nuxeo.ecm.core.api.trash.TrashService.Feature.TRASHED_STATE_IS_DEDICATED_PROPERTY; 025import static org.nuxeo.ecm.core.api.trash.TrashService.Feature.TRASHED_STATE_IS_DEDUCED_FROM_LIFECYCLE; 026 027import java.util.ArrayList; 028import java.util.Arrays; 029import java.util.Calendar; 030import java.util.Collections; 031import java.util.Deque; 032import java.util.HashSet; 033import java.util.LinkedList; 034import java.util.List; 035import java.util.Set; 036import java.util.regex.Pattern; 037 038import org.apache.commons.lang3.CharUtils; 039import org.apache.commons.lang3.StringUtils; 040import org.nuxeo.ecm.core.api.LifeCycleConstants; 041import org.nuxeo.ecm.core.api.trash.TrashService; 042import org.nuxeo.ecm.core.query.QueryParseException; 043import org.nuxeo.ecm.core.query.sql.NXQL; 044import org.nuxeo.ecm.core.query.sql.model.BooleanLiteral; 045import org.nuxeo.ecm.core.query.sql.model.DateLiteral; 046import org.nuxeo.ecm.core.query.sql.model.DoubleLiteral; 047import org.nuxeo.ecm.core.query.sql.model.Expression; 048import org.nuxeo.ecm.core.query.sql.model.Function; 049import org.nuxeo.ecm.core.query.sql.model.IntegerLiteral; 050import org.nuxeo.ecm.core.query.sql.model.Literal; 051import org.nuxeo.ecm.core.query.sql.model.LiteralList; 052import org.nuxeo.ecm.core.query.sql.model.MultiExpression; 053import org.nuxeo.ecm.core.query.sql.model.Operand; 054import org.nuxeo.ecm.core.query.sql.model.Operator; 055import org.nuxeo.ecm.core.query.sql.model.Predicate; 056import org.nuxeo.ecm.core.query.sql.model.Reference; 057import org.nuxeo.ecm.core.query.sql.model.StringLiteral; 058import org.nuxeo.runtime.api.Framework; 059 060import com.google.common.collect.Iterators; 061import com.google.common.collect.PeekingIterator; 062 063/** 064 * Evaluator for an {@link Expression}. 065 * 066 * @since 5.9.4 067 */ 068public abstract class ExpressionEvaluator { 069 070 /** pseudo NXQL to resolve ancestor ids. */ 071 public static final String NXQL_ECM_ANCESTOR_IDS = "ecm:__ancestorIds"; 072 073 /** pseudo NXQL to resolve internal path. */ 074 public static final String NXQL_ECM_PATH = "ecm:__path"; 075 076 /** pseudo NXQL to resolve read acls. */ 077 public static final String NXQL_ECM_READ_ACL = "ecm:__read_acl"; 078 079 public static final String NXQL_ECM_FULLTEXT_SIMPLE = "ecm:__fulltextSimple"; 080 081 public static final String NXQL_ECM_FULLTEXT_BINARY = "ecm:__fulltextBinary"; 082 083 protected static final String DATE_CAST = "DATE"; 084 085 protected static final String PHRASE_QUOTE = "\""; 086 087 protected static final String NEG_PHRASE_QUOTE = "-\""; 088 089 protected static final String OR = "or"; 090 091 /** 092 * Interface for a class that knows how to resolve a path into an id. 093 */ 094 public interface PathResolver { 095 /** 096 * Returns the id for a given path. 097 * 098 * @param path the path 099 * @return the id, or {@code null} if not found 100 */ 101 String getIdForPath(String path); 102 } 103 104 public final PathResolver pathResolver; 105 106 public final Set<String> principals; 107 108 public final boolean fulltextSearchDisabled; 109 110 public boolean hasFulltext; 111 112 public ExpressionEvaluator(PathResolver pathResolver, String[] principals, boolean fulltextSearchDisabled) { 113 this.pathResolver = pathResolver; 114 this.principals = principals == null ? null : new HashSet<>(Arrays.asList(principals)); 115 this.fulltextSearchDisabled = fulltextSearchDisabled; 116 } 117 118 public Object walkExpression(Expression expr) { 119 Operator op = expr.operator; 120 Operand lvalue = expr.lvalue; 121 Operand rvalue = expr.rvalue; 122 Reference ref = lvalue instanceof Reference ? (Reference) lvalue : null; 123 String name = ref != null ? ref.name : null; 124 String cast = ref != null ? ref.cast : null; 125 if (DATE_CAST.equals(cast)) { 126 checkDateLiteralForCast(rvalue, name); 127 } 128 if (op == Operator.STARTSWITH) { 129 return walkStartsWith(lvalue, rvalue); 130 } else if (NXQL.ECM_PATH.equals(name)) { 131 return walkEcmPath(op, rvalue); 132 } else if (NXQL.ECM_ANCESTORID.equals(name)) { 133 return walkAncestorId(op, rvalue); 134 } else if (NXQL.ECM_ISTRASHED.equals(name)) { 135 return walkIsTrashed(op, rvalue); 136 } else if (name != null && name.startsWith(NXQL.ECM_FULLTEXT) && !NXQL.ECM_FULLTEXT_JOBID.equals(name)) { 137 return walkEcmFulltext(name, op, rvalue); 138 } else if (op == Operator.SUM) { 139 throw new UnsupportedOperationException("SUM"); 140 } else if (op == Operator.SUB) { 141 throw new UnsupportedOperationException("SUB"); 142 } else if (op == Operator.MUL) { 143 throw new UnsupportedOperationException("MUL"); 144 } else if (op == Operator.DIV) { 145 throw new UnsupportedOperationException("DIV"); 146 } else if (op == Operator.LT) { 147 return walkLt(lvalue, rvalue); 148 } else if (op == Operator.GT) { 149 return walkGt(lvalue, rvalue); 150 } else if (op == Operator.EQ) { 151 return walkEq(lvalue, rvalue); 152 } else if (op == Operator.NOTEQ) { 153 return walkNotEq(lvalue, rvalue); 154 } else if (op == Operator.LTEQ) { 155 return walkLtEq(lvalue, rvalue); 156 } else if (op == Operator.GTEQ) { 157 return walkGtEq(lvalue, rvalue); 158 } else if (op == Operator.AND) { 159 if (expr instanceof MultiExpression) { 160 return walkMultiExpression((MultiExpression) expr); 161 } else { 162 return walkAnd(lvalue, rvalue); 163 } 164 } else if (op == Operator.NOT) { 165 return walkNot(lvalue); 166 } else if (op == Operator.OR) { 167 if (expr instanceof MultiExpression) { 168 return walkMultiExpression((MultiExpression) expr); 169 } else { 170 return walkOr(lvalue, rvalue); 171 } 172 } else if (op == Operator.LIKE) { 173 return walkLike(lvalue, rvalue, true, false); 174 } else if (op == Operator.ILIKE) { 175 return walkLike(lvalue, rvalue, true, true); 176 } else if (op == Operator.NOTLIKE) { 177 return walkLike(lvalue, rvalue, false, false); 178 } else if (op == Operator.NOTILIKE) { 179 return walkLike(lvalue, rvalue, false, true); 180 } else if (op == Operator.IN) { 181 return walkIn(lvalue, rvalue, true); 182 } else if (op == Operator.NOTIN) { 183 return walkIn(lvalue, rvalue, false); 184 } else if (op == Operator.ISNULL) { 185 return walkIsNull(lvalue); 186 } else if (op == Operator.ISNOTNULL) { 187 return walkIsNotNull(lvalue); 188 } else if (op == Operator.BETWEEN) { 189 return walkBetween(lvalue, rvalue, true); 190 } else if (op == Operator.NOTBETWEEN) { 191 return walkBetween(lvalue, rvalue, false); 192 } else { 193 throw new QueryParseException("Unknown operator: " + op); 194 } 195 } 196 197 protected void checkDateLiteralForCast(Operand value, String name) { 198 if (value instanceof DateLiteral && !((DateLiteral) value).onlyDate) { 199 throw new QueryParseException("DATE() cast must be used with DATE literal, not TIMESTAMP: " + name); 200 } 201 } 202 203 protected Boolean walkEcmPath(Operator op, Operand rvalue) { 204 if (op != Operator.EQ && op != Operator.NOTEQ) { 205 throw new QueryParseException(NXQL.ECM_PATH + " requires = or <> operator"); 206 } 207 if (!(rvalue instanceof StringLiteral)) { 208 throw new QueryParseException(NXQL.ECM_PATH + " requires literal path as right argument"); 209 } 210 String path = ((StringLiteral) rvalue).value; 211 if (path.length() > 1 && path.endsWith("/")) { 212 path = path.substring(0, path.length() - 1); 213 } 214 String id = pathResolver.getIdForPath(path); 215 Object right = walkReference(new Reference(NXQL.ECM_UUID)); 216 if (id == null) { 217 return FALSE; 218 } 219 Boolean eq = eq(id, right); 220 return op == Operator.EQ ? eq : not(eq); 221 } 222 223 protected Boolean walkAncestorId(Operator op, Operand rvalue) { 224 if (op != Operator.EQ && op != Operator.NOTEQ) { 225 throw new QueryParseException(NXQL.ECM_ANCESTORID + " requires = or <> operator"); 226 } 227 if (!(rvalue instanceof StringLiteral)) { 228 throw new QueryParseException(NXQL.ECM_ANCESTORID + " requires literal id as right argument"); 229 } 230 String ancestorId = ((StringLiteral) rvalue).value; 231 Object[] ancestorIds = (Object[]) walkReference(new Reference(NXQL_ECM_ANCESTOR_IDS)); 232 boolean eq = op == Operator.EQ; 233 if (ancestorIds == null) { 234 // placeless 235 return eq ? FALSE : TRUE; 236 } 237 for (Object id : ancestorIds) { 238 if (ancestorId.equals(id)) { 239 return eq ? TRUE : FALSE; 240 } 241 } 242 return eq ? FALSE : TRUE; 243 } 244 245 protected Boolean walkEcmFulltext(String name, Operator op, Operand rvalue) { 246 if (op != Operator.EQ && op != Operator.LIKE) { 247 throw new QueryParseException(NXQL.ECM_FULLTEXT + " requires = or LIKE operator"); 248 } 249 if (!(rvalue instanceof StringLiteral)) { 250 throw new QueryParseException(NXQL.ECM_FULLTEXT + " requires literal string as right argument"); 251 } 252 if (fulltextSearchDisabled) { 253 throw new QueryParseException("Fulltext search disabled by configuration"); 254 } 255 String query = ((StringLiteral) rvalue).value; 256 if (name.equals(NXQL.ECM_FULLTEXT)) { 257 // standard fulltext query 258 hasFulltext = true; 259 String simple = (String) walkReference(new Reference(NXQL_ECM_FULLTEXT_SIMPLE)); 260 String binary = (String) walkReference(new Reference(NXQL_ECM_FULLTEXT_BINARY)); 261 return fulltext(simple, binary, query); 262 } else { 263 // secondary index match with explicit field 264 // do a regexp on the field 265 if (name.charAt(NXQL.ECM_FULLTEXT.length()) != '.') { 266 throw new QueryParseException(name + " has incorrect syntax for a secondary fulltext index"); 267 } 268 String prop = name.substring(NXQL.ECM_FULLTEXT.length() + 1); 269 String ft = query.replace(" ", "%"); 270 rvalue = new StringLiteral(ft); 271 return walkLike(new Reference(prop), rvalue, true, true); 272 } 273 } 274 275 protected Boolean walkIsTrashed(Operator op, Operand rvalue) { 276 if (op != Operator.EQ && op != Operator.NOTEQ) { 277 throw new QueryParseException(NXQL.ECM_ISTRASHED + " requires = or <> operator"); 278 } 279 TrashService trashService = Framework.getService(TrashService.class); 280 if (trashService.hasFeature(TRASHED_STATE_IS_DEDUCED_FROM_LIFECYCLE)) { 281 return walkIsTrashed(new Reference(NXQL.ECM_LIFECYCLESTATE), op, rvalue, 282 new StringLiteral(LifeCycleConstants.DELETED_STATE)); 283 } else if (trashService.hasFeature(TRASHED_STATE_IN_MIGRATION)) { 284 Boolean lifeCycleTrashed = walkIsTrashed(new Reference(NXQL.ECM_LIFECYCLESTATE), op, rvalue, 285 new StringLiteral(LifeCycleConstants.DELETED_STATE)); 286 Boolean propertyTrashed = walkIsTrashed(new Reference(NXQL.ECM_ISTRASHED), op, rvalue, 287 new IntegerLiteral(1L)); 288 return or(lifeCycleTrashed, propertyTrashed); 289 } else if (trashService.hasFeature(TRASHED_STATE_IS_DEDICATED_PROPERTY)) { 290 return walkIsTrashed(new Reference(NXQL.ECM_ISTRASHED), op, rvalue, new IntegerLiteral(1L)); 291 } else { 292 throw new UnsupportedOperationException("TrashService is in an unknown state"); 293 } 294 } 295 296 protected Boolean walkIsTrashed(Reference ref, Operator op, Operand initialRvalue, Literal deletedRvalue) { 297 long v; 298 if (!(initialRvalue instanceof IntegerLiteral) 299 || ((v = ((IntegerLiteral) initialRvalue).value) != 0 && v != 1)) { 300 throw new QueryParseException(NXQL.ECM_ISTRASHED + " requires literal 0 or 1 as right argument"); 301 } 302 boolean equalsDeleted = op == Operator.EQ ^ v == 0; 303 if (equalsDeleted) { 304 return walkEq(ref, deletedRvalue); 305 } else { 306 return walkNotEq(ref, deletedRvalue); 307 } 308 } 309 310 public Boolean walkNot(Operand value) { 311 return not(bool(walkOperand(value))); 312 } 313 314 public Boolean walkIsNull(Operand value) { 315 return Boolean.valueOf(walkOperand(value) == null); 316 } 317 318 public Boolean walkIsNotNull(Operand value) { 319 return Boolean.valueOf(walkOperand(value) != null); 320 } 321 322 // ternary logic 323 public Boolean walkMultiExpression(MultiExpression expr) { 324 boolean and = expr.operator == Operator.AND; 325 Boolean res = and ? TRUE : FALSE; 326 for (Predicate predicate : expr.predicates) { 327 Boolean bool = bool(walkExpression(predicate)); 328 // don't short-circuit on null, we want to walk all references deterministically 329 if (and) { 330 res = and(res, bool); 331 } else { 332 res = or(res, bool); 333 } 334 } 335 return res; 336 } 337 338 public Boolean walkAnd(Operand lvalue, Operand rvalue) { 339 Boolean left = bool(walkOperand(lvalue)); 340 Boolean right = bool(walkOperand(rvalue)); 341 return and(left, right); 342 } 343 344 public Boolean walkOr(Operand lvalue, Operand rvalue) { 345 Boolean left = bool(walkOperand(lvalue)); 346 Boolean right = bool(walkOperand(rvalue)); 347 return or(left, right); 348 } 349 350 public Boolean walkEq(Operand lvalue, Operand rvalue) { 351 Object right = walkOperand(rvalue); 352 if (isMixinTypes(lvalue)) { 353 if (!(right instanceof String)) { 354 throw new QueryParseException("Invalid EQ rhs: " + rvalue); 355 } 356 return walkMixinTypes(Collections.singletonList((String) right), true); 357 } 358 Object left = walkOperand(lvalue); 359 return eqMaybeList(left, right); 360 } 361 362 public Boolean walkNotEq(Operand lvalue, Operand rvalue) { 363 if (isMixinTypes(lvalue)) { 364 Object right = walkOperand(rvalue); 365 if (!(right instanceof String)) { 366 throw new QueryParseException("Invalid NE rhs: " + rvalue); 367 } 368 return walkMixinTypes(Collections.singletonList((String) right), false); 369 } 370 return not(walkEq(lvalue, rvalue)); 371 } 372 373 public Boolean walkLt(Operand lvalue, Operand rvalue) { 374 Integer cmp = cmp(lvalue, rvalue); 375 return cmp == null ? null : cmp < 0; 376 } 377 378 public Boolean walkGt(Operand lvalue, Operand rvalue) { 379 Integer cmp = cmp(lvalue, rvalue); 380 return cmp == null ? null : cmp > 0; 381 } 382 383 public Boolean walkLtEq(Operand lvalue, Operand rvalue) { 384 Integer cmp = cmp(lvalue, rvalue); 385 return cmp == null ? null : cmp <= 0; 386 } 387 388 public Boolean walkGtEq(Operand lvalue, Operand rvalue) { 389 Integer cmp = cmp(lvalue, rvalue); 390 return cmp == null ? null : cmp >= 0; 391 } 392 393 public Object walkBetween(Operand lvalue, Operand rvalue, boolean positive) { 394 LiteralList l = (LiteralList) rvalue; 395 Predicate va = new Predicate(lvalue, Operator.GTEQ, l.get(0)); 396 Predicate vb = new Predicate(lvalue, Operator.LTEQ, l.get(1)); 397 Predicate pred = new Predicate(va, Operator.AND, vb); 398 if (!positive) { 399 pred = new Predicate(pred, Operator.NOT, null); 400 } 401 return walkExpression(pred); 402 } 403 404 public Boolean walkIn(Operand lvalue, Operand rvalue, boolean positive) { 405 Object right = walkOperand(rvalue); 406 if (!(right instanceof List)) { 407 throw new QueryParseException("Invalid IN rhs: " + rvalue); 408 } 409 if (isMixinTypes(lvalue)) { 410 return walkMixinTypes((List<String>) right, positive); 411 } 412 Object left = walkOperand(lvalue); 413 Boolean in = inMaybeList(left, (List<Object>) right); 414 return positive ? in : not(in); 415 } 416 417 public Object walkOperand(Operand op) { 418 if (op instanceof Literal) { 419 return walkLiteral((Literal) op); 420 } else if (op instanceof LiteralList) { 421 return walkLiteralList((LiteralList) op); 422 } else if (op instanceof Function) { 423 return walkFunction((Function) op); 424 } else if (op instanceof Expression) { 425 return walkExpression((Expression) op); 426 } else if (op instanceof Reference) { 427 return walkReference((Reference) op); 428 } else { 429 throw new QueryParseException("Unknown operand: " + op); 430 } 431 } 432 433 public Object walkLiteral(Literal lit) { 434 if (lit instanceof BooleanLiteral) { 435 return walkBooleanLiteral((BooleanLiteral) lit); 436 } else if (lit instanceof DateLiteral) { 437 return walkDateLiteral((DateLiteral) lit); 438 } else if (lit instanceof DoubleLiteral) { 439 return walkDoubleLiteral((DoubleLiteral) lit); 440 } else if (lit instanceof IntegerLiteral) { 441 return walkIntegerLiteral((IntegerLiteral) lit); 442 } else if (lit instanceof StringLiteral) { 443 return walkStringLiteral((StringLiteral) lit); 444 } else { 445 throw new QueryParseException("Unknown literal: " + lit); 446 } 447 } 448 449 public Boolean walkBooleanLiteral(BooleanLiteral lit) { 450 return Boolean.valueOf(lit.value); 451 } 452 453 public Calendar walkDateLiteral(DateLiteral lit) { 454 if (lit.onlyDate) { 455 Calendar date = lit.toCalendar(); 456 if (date != null) { 457 date.set(Calendar.HOUR_OF_DAY, 0); 458 date.set(Calendar.MINUTE, 0); 459 date.set(Calendar.SECOND, 0); 460 date.set(Calendar.MILLISECOND, 0); 461 } 462 return date; 463 } else { 464 return lit.toCalendar(); 465 } 466 } 467 468 public Double walkDoubleLiteral(DoubleLiteral lit) { 469 return Double.valueOf(lit.value); 470 } 471 472 public Long walkIntegerLiteral(IntegerLiteral lit) { 473 return Long.valueOf(lit.value); 474 } 475 476 public String walkStringLiteral(StringLiteral lit) { 477 return lit.value; 478 } 479 480 public List<Object> walkLiteralList(LiteralList litList) { 481 List<Object> list = new ArrayList<>(litList.size()); 482 for (Literal lit : litList) { 483 list.add(walkLiteral(lit)); 484 } 485 return list; 486 } 487 488 public Boolean walkLike(Operand lvalue, Operand rvalue, boolean positive, boolean caseInsensitive) { 489 Object left = walkOperand(lvalue); 490 Object right = walkOperand(rvalue); 491 if (!(right instanceof String)) { 492 throw new QueryParseException("Invalid LIKE rhs: " + rvalue); 493 } 494 return likeMaybeList(left, (String) right, positive, caseInsensitive); 495 } 496 497 public Object walkFunction(Function func) { 498 throw new UnsupportedOperationException("Function"); 499 } 500 501 public Boolean walkStartsWith(Operand lvalue, Operand rvalue) { 502 if (!(lvalue instanceof Reference)) { 503 throw new QueryParseException("Invalid STARTSWITH query, left hand side must be a property: " + lvalue); 504 } 505 String name = ((Reference) lvalue).name; 506 if (!(rvalue instanceof StringLiteral)) { 507 throw new QueryParseException( 508 "Invalid STARTSWITH query, right hand side must be a literal path: " + rvalue); 509 } 510 String path = ((StringLiteral) rvalue).value; 511 if (path.length() > 1 && path.endsWith("/")) { 512 path = path.substring(0, path.length() - 1); 513 } 514 515 if (NXQL.ECM_PATH.equals(name)) { 516 return walkStartsWithPath(path); 517 } else { 518 return walkStartsWithNonPath(lvalue, path); 519 } 520 } 521 522 protected Boolean walkStartsWithPath(String path) { 523 // resolve path 524 String ancestorId = pathResolver.getIdForPath(path); 525 // don't return early on null ancestorId, we want to walk all references deterministically 526 Object[] ancestorIds = (Object[]) walkReference(new Reference(NXQL_ECM_ANCESTOR_IDS)); 527 if (ancestorId == null) { 528 // no such path 529 return FALSE; 530 } 531 if (ancestorIds == null) { 532 // placeless 533 return FALSE; 534 } 535 for (Object id : ancestorIds) { 536 if (ancestorId.equals(id)) { 537 return TRUE; 538 } 539 } 540 return FALSE; 541 } 542 543 protected Boolean walkStartsWithNonPath(Operand lvalue, String path) { 544 Object left = walkReference((Reference) lvalue); 545 // exact match 546 Boolean bool = eqMaybeList(left, path); 547 if (TRUE.equals(bool)) { 548 return TRUE; 549 } 550 // prefix match TODO escape % chars 551 String pattern = path + "/%"; 552 return likeMaybeList(left, pattern, true, false); 553 } 554 555 /** 556 * Evaluates a reference over the context state. 557 * 558 * @param ref the reference 559 */ 560 public abstract Object walkReference(Reference ref); 561 562 protected boolean isMixinTypes(Operand op) { 563 if (!(op instanceof Reference)) { 564 return false; 565 } 566 return ((Reference) op).name.equals(NXQL.ECM_MIXINTYPE); 567 } 568 569 protected Boolean bool(Object value) { 570 if (value == null) { 571 return null; // NOSONAR 572 } 573 if (!(value instanceof Boolean)) { 574 throw new QueryParseException("Not a boolean: " + value); 575 } 576 return (Boolean) value; 577 } 578 579 // ternary logic 580 protected Boolean not(Boolean value) { 581 if (value == null) { 582 return null; // NOSONAR 583 } 584 return !value; 585 } 586 587 // ternary logic 588 protected Boolean and(Boolean left, Boolean right) { 589 if (TRUE.equals(left)) { 590 return right; 591 } else { 592 return left; 593 } 594 } 595 596 // ternary logic 597 protected Boolean or(Boolean left, Boolean right) { 598 if (TRUE.equals(left)) { 599 return left; 600 } else { 601 return right; 602 } 603 } 604 605 // ternary logic 606 protected Boolean eq(Object left, Object right) { 607 if (left == null || right == null) { 608 return null; // NOSONAR 609 } 610 if (left instanceof Calendar && right instanceof Calendar) { 611 // avoid timezone issues (NXP-20260) 612 return ((Calendar) left).getTimeInMillis() == ((Calendar) right).getTimeInMillis(); 613 } 614 return left.equals(right); 615 } 616 617 // ternary logic 618 protected Boolean in(Object left, List<Object> right) { 619 if (left == null) { 620 return null; // NOSONAR 621 } 622 boolean hasNull = false; 623 for (Object r : right) { 624 if (r == null) { 625 hasNull = true; 626 } else if (left.equals(r)) { 627 return TRUE; 628 } 629 } 630 return hasNull ? null : FALSE; 631 } 632 633 protected Integer cmp(Operand lvalue, Operand rvalue) { 634 Object left = walkOperand(lvalue); 635 Object right = walkOperand(rvalue); 636 return cmp(left, right); 637 } 638 639 // ternary logic 640 protected Integer cmp(Object left, Object right) { 641 if (left == null || right == null) { 642 return null; 643 } 644 if (!(left instanceof Comparable)) { 645 throw new QueryParseException("Not a comparable: " + left); 646 } 647 return ((Comparable<Object>) left).compareTo(right); 648 } 649 650 // ternary logic 651 protected Boolean like(Object left, String right, boolean caseInsensitive) { 652 if (left == null || right == null) { 653 return null; // NOSONAR 654 } 655 if (!(left instanceof String)) { 656 throw new QueryParseException("Invalid LIKE lhs: " + left); 657 } 658 String value = (String) left; 659 if (caseInsensitive) { 660 value = value.toLowerCase(); 661 right = right.toLowerCase(); 662 } 663 String regex = likeToRegex(right); 664 return Pattern.matches(regex, value); 665 } 666 667 /** 668 * Turns a NXQL LIKE pattern into a regex. 669 * <p> 670 * % and _ are standard wildcards, and \ escapes them. 671 * 672 * @since 7.4 673 */ 674 public static String likeToRegex(String like) { 675 StringBuilder regex = new StringBuilder(); 676 char[] chars = like.toCharArray(); 677 boolean escape = false; 678 for (int i = 0; i < chars.length; i++) { 679 char c = chars[i]; 680 boolean escapeNext = false; 681 switch (c) { 682 case '%': 683 if (escape) { 684 regex.append(c); 685 } else { 686 regex.append(".*"); 687 } 688 break; 689 case '_': 690 if (escape) { 691 regex.append(c); 692 } else { 693 regex.append("."); 694 } 695 break; 696 case '\\': 697 if (escape) { 698 regex.append("\\\\"); // backslash escaped for regexp 699 } else { 700 escapeNext = true; 701 } 702 break; 703 default: 704 // escape mostly everything just in case 705 if (!CharUtils.isAsciiAlphanumeric(c)) { 706 regex.append("\\"); 707 } 708 regex.append(c); 709 break; 710 } 711 escape = escapeNext; 712 } 713 if (escape) { 714 // invalid string terminated by escape character, ignore 715 } 716 return regex.toString(); 717 } 718 719 // if list, use EXIST (SELECT 1 FROM left WHERE left.item = right) 720 protected Boolean eqMaybeList(Object left, Object right) { 721 if (left instanceof Object[]) { 722 for (Object l : ((Object[]) left)) { 723 Boolean eq = eq(l, right); 724 if (TRUE.equals(eq)) { 725 return TRUE; 726 } 727 } 728 return FALSE; 729 } else { 730 return eq(left, right); 731 } 732 } 733 734 // if list, use EXIST (SELECT 1 FROM left WHERE left.item IN right) 735 protected Boolean inMaybeList(Object left, List<Object> right) { 736 if (left instanceof Object[]) { 737 for (Object l : ((Object[]) left)) { 738 Boolean in = in(l, right); 739 if (TRUE.equals(in)) { 740 return TRUE; 741 } 742 } 743 return FALSE; 744 } else { 745 return in(left, right); 746 } 747 } 748 749 protected Boolean likeMaybeList(Object left, String right, boolean positive, boolean caseInsensitive) { 750 if (left instanceof Object[]) { 751 for (Object l : ((Object[]) left)) { 752 Boolean like = like(l, right, caseInsensitive); 753 if (TRUE.equals(like)) { 754 return Boolean.valueOf(positive); 755 } 756 } 757 return Boolean.valueOf(!positive); 758 } else { 759 Boolean like = like(left, right, caseInsensitive); 760 return positive ? like : not(like); 761 } 762 } 763 764 /** 765 * Matches the mixin types against a list of values. 766 * <p> 767 * Used for: 768 * <ul> 769 * <li>ecm:mixinTypes = 'foo' 770 * <li>ecm:mixinTypes != 'foo' 771 * <li>ecm:mixinTypes IN ('foo', 'bar') 772 * <li>ecm:mixinTypes NOT IN ('foo', 'bar') 773 * </ul> 774 * 775 * @param mixins the mixin(s) to match 776 * @param include {@code true} for = and IN 777 * @since 7.4 778 */ 779 public abstract Boolean walkMixinTypes(List<String> mixins, boolean include); 780 781 /* 782 * ----- simple parsing, don't try to be exhaustive ----- 783 */ 784 785 private static final Pattern WORD_PATTERN = Pattern.compile("[\\s\\p{Punct}]+"); 786 787 private static final String UNACCENTED = "aaaaaaaceeeeiiii\u00f0nooooo\u00f7ouuuuy\u00fey"; 788 789 private static final String STOP_WORDS_STR = "a an are and as at be by for from how " // 790 + "i in is it of on or that the this to was what when where who will with " // 791 + "car donc est il ils je la le les mais ni nous or ou pour tu un une vous " // 792 + "www com net org"; 793 794 private static final Set<String> STOP_WORDS = new HashSet<>(Arrays.asList(StringUtils.split(STOP_WORDS_STR, ' '))); 795 796 /** 797 * Checks if the fulltext combination of string1 and string2 matches the query expression. 798 */ 799 protected static Boolean fulltext(String string1, String string2, String queryString) { 800 if (queryString == null || (string1 == null && string2 == null)) { 801 return null; // NOSONAR 802 } 803 // query 804 List<String> query = new ArrayList<>(); 805 String phrase = null; 806 int phraseWordCount = 1; 807 int maxPhraseWordCount = 1; // maximum number of words in a phrase 808 for (String word : StringUtils.split(queryString.toLowerCase(), ' ')) { 809 if (WORD_PATTERN.matcher(word).matches()) { 810 continue; 811 } 812 if (phrase != null) { 813 if (word.endsWith(PHRASE_QUOTE)) { 814 phrase += " " + word.substring(0, word.length() - 1); 815 query.add(phrase); 816 phraseWordCount++; 817 if (maxPhraseWordCount < phraseWordCount) { 818 maxPhraseWordCount = phraseWordCount; 819 } 820 phrase = null; 821 phraseWordCount = 1; 822 } else { 823 phrase += " " + word; 824 phraseWordCount++; 825 } 826 } else { 827 if (word.startsWith(PHRASE_QUOTE)) { 828 phrase = word.substring(1); 829 } else if (word.startsWith(NEG_PHRASE_QUOTE)) { 830 phrase = "-" + word.substring(2); 831 } else { 832 if (word.startsWith("+")) { 833 word = word.substring(1); 834 } 835 query.add(word); 836 } 837 } 838 } 839 if (query.isEmpty()) { 840 return FALSE; 841 } 842 // fulltext 843 Set<String> fulltext = new HashSet<>(); 844 fulltext.addAll(parseFullText(string1, maxPhraseWordCount)); 845 fulltext.addAll(parseFullText(string2, maxPhraseWordCount)); 846 847 return Boolean.valueOf(fulltext(fulltext, query)); 848 } 849 850 private static Set<String> parseFullText(String string, int phraseSize) { 851 if (string == null) { 852 return Collections.emptySet(); 853 } 854 Set<String> set = new HashSet<>(); 855 Deque<String> phraseWords = new LinkedList<>(); 856 for (String word : WORD_PATTERN.split(string)) { 857 word = parseWord(word); 858 if (word != null) { 859 word = word.toLowerCase(); 860 set.add(word); 861 if (phraseSize > 1) { 862 phraseWords.addLast(word); 863 if (phraseWords.size() > 1) { 864 if (phraseWords.size() > phraseSize) { 865 phraseWords.removeFirst(); 866 } 867 addPhraseWords(set, phraseWords); 868 } 869 } 870 } 871 } 872 while (phraseWords.size() > 2) { 873 phraseWords.removeFirst(); 874 addPhraseWords(set, phraseWords); 875 } 876 return set; 877 } 878 879 /** 880 * Adds to the set all the sub-phrases from the start of the phraseWords. 881 */ 882 private static void addPhraseWords(Set<String> set, Deque<String> phraseWords) { 883 String[] array = phraseWords.toArray(new String[0]); 884 for (int len = 2; len <= array.length; len++) { 885 String phrase = StringUtils.join(array, ' ', 0, len); 886 set.add(phrase); 887 } 888 } 889 890 private static String parseWord(String string) { 891 int len = string.length(); 892 if (len < 3) { 893 return null; 894 } 895 StringBuilder sb = new StringBuilder(len); 896 for (int i = 0; i < len; i++) { 897 char c = Character.toLowerCase(string.charAt(i)); 898 if (c == '\u00e6') { 899 sb.append("ae"); 900 } else if (c >= '\u00e0' && c <= '\u00ff') { 901 sb.append(UNACCENTED.charAt((c) - 0xe0)); 902 } else if (c == '\u0153') { 903 sb.append("oe"); 904 } else { 905 sb.append(c); 906 } 907 } 908 // simple heuristic to remove plurals 909 int l = sb.length(); 910 if (l > 3 && sb.charAt(l - 1) == 's') { 911 sb.setLength(l - 1); 912 } 913 String word = sb.toString(); 914 if (STOP_WORDS.contains(word)) { 915 return null; 916 } 917 return word; 918 } 919 920 // matches "foo OR bar baz" as "foo OR (bar AND baz)" 921 protected static boolean fulltext(Set<String> fulltext, List<String> query) { 922 boolean andMatch = true; 923 for (PeekingIterator<String> it = Iterators.peekingIterator(query.iterator()); it.hasNext();) { 924 String word = it.next(); 925 boolean match; 926 if (word.endsWith("*") || word.endsWith("%")) { 927 // prefix match 928 match = false; 929 String prefix = word.substring(0, word.length() - 2); 930 for (String candidate : fulltext) { 931 if (candidate.startsWith(prefix)) { 932 match = true; 933 break; 934 } 935 } 936 } else { 937 if (word.startsWith("-")) { 938 word = word.substring(1);// 939 match = !fulltext.contains(word); 940 } else { 941 match = fulltext.contains(word); 942 } 943 } 944 if (!match) { 945 andMatch = false; 946 } 947 if (it.hasNext() && it.peek().equals(OR)) { 948 // end of AND group 949 // swallow OR 950 it.next(); 951 // return if the previous AND group matched 952 if (andMatch) { 953 return true; 954 } 955 // else start next AND group 956 andMatch = true; 957 } 958 } 959 return andMatch; 960 } 961 962 // matches "foo OR bar baz" as "(foo OR bar) AND baz" 963 protected static boolean fulltext1(Set<String> fulltext, List<String> query) { 964 boolean inOr = false; // if we're in a OR group 965 boolean orMatch = false; // value of the OR group 966 for (PeekingIterator<String> it = Iterators.peekingIterator(query.iterator()); it.hasNext();) { 967 String word = it.next(); 968 if (it.hasNext() && it.peek().equals(OR)) { 969 inOr = true; 970 orMatch = false; 971 } 972 boolean match; 973 if (word.endsWith("*") || word.endsWith("%")) { 974 // prefix match 975 match = false; 976 String prefix = word.substring(0, word.length() - 2); 977 for (String candidate : fulltext) { 978 if (candidate.startsWith(prefix)) { 979 match = true; 980 break; 981 } 982 } 983 } else { 984 if (word.startsWith("-")) { 985 word = word.substring(1);// 986 match = !fulltext.contains(word); 987 } else { 988 match = fulltext.contains(word); 989 } 990 } 991 if (inOr) { 992 if (match) { 993 orMatch = true; 994 } 995 if (it.hasNext() && it.peek().equals(OR)) { 996 // swallow OR and keep going in OR group 997 it.next(); 998 continue; 999 } 1000 // finish OR group 1001 match = orMatch; 1002 inOr = false; 1003 } 1004 if (!match) { 1005 return false; 1006 } 1007 } 1008 if (inOr) { 1009 // trailing OR, ignore and finish previous group 1010 if (!orMatch) { 1011 return false; 1012 } 1013 } 1014 return true; 1015 } 1016 1017}