001/* 002 * Copyright 2015 Anyware Services 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 */ 016package org.ametys.cms.search; 017 018import java.util.ArrayList; 019import java.util.Collection; 020import java.util.Collections; 021import java.util.HashMap; 022import java.util.HashSet; 023import java.util.List; 024import java.util.Map; 025import java.util.Optional; 026import java.util.Set; 027import java.util.stream.Collectors; 028 029import org.apache.avalon.framework.component.Component; 030import org.apache.avalon.framework.logger.AbstractLogEnabled; 031import org.apache.avalon.framework.service.ServiceException; 032import org.apache.avalon.framework.service.ServiceManager; 033import org.apache.avalon.framework.service.Serviceable; 034import org.apache.commons.collections4.CollectionUtils; 035import org.apache.commons.lang3.StringUtils; 036import org.apache.solr.client.solrj.util.ClientUtils; 037 038import org.ametys.cms.contenttype.ContentType; 039import org.ametys.cms.contenttype.ContentTypeExtensionPoint; 040import org.ametys.cms.languages.Language; 041import org.ametys.cms.languages.LanguagesManager; 042import org.ametys.cms.search.model.SearchCriterion; 043import org.ametys.cms.search.model.SystemSearchCriterion; 044import org.ametys.cms.search.query.AndQuery; 045import org.ametys.cms.search.query.ContentLanguageQuery; 046import org.ametys.cms.search.query.ContentTypeQuery; 047import org.ametys.cms.search.query.MixinTypeQuery; 048import org.ametys.cms.search.query.NotQuery; 049import org.ametys.cms.search.query.OrQuery; 050import org.ametys.cms.search.query.Query; 051import org.ametys.cms.search.query.Query.Operator; 052import org.ametys.cms.search.ui.model.SearchUICriterion; 053import org.ametys.cms.search.ui.model.SearchUIModel; 054import org.ametys.cms.search.ui.model.impl.SystemSearchUICriterion; 055 056/** 057 * Builds a {@link Query} object from a user search. 058 */ 059public class QueryBuilder extends AbstractLogEnabled implements Component, Serviceable 060{ 061 /** The component role. */ 062 public static final String ROLE = QueryBuilder.class.getName(); 063 064 /** Prefix for id of metadata search criteria */ 065 public static final String SEARCH_CRITERIA_METADATA_PREFIX = "metadata-"; 066 067 /** Prefix for id of system property search criteria */ 068 public static final String SEARCH_CRITERIA_SYSTEM_PREFIX = "property-"; 069 070 /** Key of flag present in contextual parameters to indicate the current search is multilingual */ 071 public static final String MULTILINGUAL_SEARCH = "multilingualSearch"; 072 073 /** Key of flag present in contextual parameters to indicate the provided value was already escaped */ 074 public static final String VALUE_IS_ESCAPED = "isEscapedValue"; 075 076 /** The query default language. */ 077 public static final String DEFAULT_LANGUAGE = "en"; 078 079 /** The content type extension point. */ 080 protected ContentTypeExtensionPoint _cTypeEP; 081 082 /** The languages manager */ 083 protected LanguagesManager _languagesManager; 084 085 @Override 086 public void service(ServiceManager serviceManager) throws ServiceException 087 { 088 _cTypeEP = (ContentTypeExtensionPoint) serviceManager.lookup(ContentTypeExtensionPoint.ROLE); 089 _languagesManager = (LanguagesManager) serviceManager.lookup(LanguagesManager.ROLE); 090 } 091 092 /** 093 * Build the {@link Query} object. 094 * @param model the search model. 095 * @param searchMode the search mode. 096 * @param createContentTypeQuery True to generate a query based on the content types of the model, and the 'property-contentTypes-eq' if present in values 097 * @param values the user search values. 098 * @param contextualParameters the search contextual parameters. 099 * @return a {@link Query} object representing the search. 100 */ 101 public Query build(SearchUIModel model, String searchMode, boolean createContentTypeQuery, Map<String, Object> values, Map<String, Object> contextualParameters) 102 { 103 Map<String, Object> copiedValues = new HashMap<>(values); 104 List<Query> queries = new ArrayList<>(); 105 106 Set<String> modelContentTypes = model.getContentTypes(contextualParameters); 107 Set<String> modelExcludedCTypes = model.getExcludedContentTypes(contextualParameters); 108 Map<String, SearchUICriterion> criteria = searchMode.equals("advanced") ? model.getAdvancedCriteria(contextualParameters) : model.getCriteria(contextualParameters); 109 110 Query cTypeQuery = null; 111 if (createContentTypeQuery) 112 { 113 cTypeQuery = createContentTypeQuery(modelContentTypes, copiedValues, criteria, searchMode); 114 } 115 if (cTypeQuery != null) 116 { 117 queries.add(cTypeQuery); 118 } 119 120 if (!modelExcludedCTypes.isEmpty()) 121 { 122 // query is on `allContentTypes` field, so do not be redundant and only keep the top supertypes for a readable query 123 queries.add(new ContentTypeQuery(Operator.NE, _getOnlySuperTypes(modelExcludedCTypes))); 124 } 125 126 String language = getCriteriaLanguage(criteria, searchMode, copiedValues, contextualParameters); 127 128 Query mixinQuery = createMixinTypeQuery(modelContentTypes, values, criteria, searchMode); 129 if (mixinQuery != null) 130 { 131 queries.add(mixinQuery); 132 } 133 134 if (_isMultilingualSearch(cTypeQuery)) 135 { 136 contextualParameters.put(MULTILINGUAL_SEARCH, true); 137 } 138 139 if ("simple".equals(searchMode)) 140 { 141 queries.addAll(getCriteriaQueries(criteria, copiedValues, language, contextualParameters)); 142 } 143 else if ("advanced".equals(searchMode)) 144 { 145 Query advQuery = getAdvancedCriteriaQuery(criteria, copiedValues, language, contextualParameters); 146 if (advQuery != null) 147 { 148 queries.add(advQuery); 149 } 150 151 Query contentLanguageQuery = new ContentLanguageQuery(language); 152 queries.add(contentLanguageQuery); 153 } 154 155 return new AndQuery(queries); 156 } 157 158 private boolean _isMultilingualSearch(Query cTypeQuery) 159 { 160 if (cTypeQuery == null || !(cTypeQuery instanceof ContentTypeQuery)) 161 { 162 return false; 163 } 164 165 for (String cTypeId : ((ContentTypeQuery) cTypeQuery).getIds()) 166 { 167 if (!_cTypeEP.getExtension(cTypeId).isMultilingual()) 168 { 169 return false; 170 } 171 } 172 173 // All concerned content types are multilingual 174 return true; 175 } 176 177 private Set<String> _getOnlySuperTypes(Set<String> cTypes) 178 { 179 Set<String> result = new HashSet<>(); 180 181 for (String cTypeId : cTypes) 182 { 183 ContentType cType = _cTypeEP.getExtension(cTypeId); 184 if (!_containsAny(cTypes, cType.getSupertypeIds())) 185 { 186 result.add(cTypeId); 187 } 188 } 189 190 return result; 191 } 192 193 // Returns true if at least one of the supertypeIds is in cTypes 194 private boolean _containsAny(Set<String> cTypes, String[] supertypeIds) 195 { 196 for (String supertypeId : supertypeIds) 197 { 198 if (cTypes.contains(supertypeId)) 199 { 200 return true; 201 } 202 } 203 return false; 204 } 205 206 /** 207 * Get the language. 208 * @param criteria the list of search criteria 209 * @param searchMode The search mode (advanced or simple) 210 * @param values The user values. 211 * @param contextualParameters The search contextual parameters. 212 * @return the query language. 213 */ 214 public String getCriteriaLanguage(Map<String, SearchUICriterion> criteria, String searchMode, Map<String, Object> values, Map<String, Object> contextualParameters) 215 { 216 String langValue = null; 217 218 // First search language in search criteria 219 for (SearchCriterion criterion : criteria.values()) 220 { 221 if (criterion instanceof SystemSearchCriterion) 222 { 223 SystemSearchUICriterion sysCrit = (SystemSearchUICriterion) criterion; 224 if (sysCrit.getSystemPropertyId().equals("contentLanguage")) 225 { 226 if (sysCrit.isHidden()) 227 { 228 // Use the default value 229 langValue = (String) sysCrit.getDefaultValue(); 230 } 231 else 232 { 233 // Use the user input 234 langValue = (String) values.get(sysCrit.getId()); 235 } 236 break; 237 } 238 } 239 } 240 241 if (StringUtils.isEmpty(langValue) || "CURRENT".equals(langValue)) 242 { 243 // If empty, get language from the search contextual parameters (for instance, sent by the tool). 244 langValue = (String) contextualParameters.get("language"); 245 } 246 247 if (StringUtils.isEmpty(langValue)) 248 { 249 // If no language found: fall back to default. 250 langValue = getDefaultLanguage(); 251 } 252 253 return langValue; 254 } 255 256 /** 257 * Get the default language for search 258 * @return The default language 259 */ 260 protected String getDefaultLanguage() 261 { 262 Map<String, Language> availableLanguages = _languagesManager.getAvailableLanguages(); 263 if (availableLanguages.containsKey(DEFAULT_LANGUAGE)) 264 { 265 return DEFAULT_LANGUAGE; 266 } 267 268 return availableLanguages.size() > 0 ? availableLanguages.keySet().iterator().next() : DEFAULT_LANGUAGE; 269 } 270 271 /** 272 * Create a content type query. 273 * @param modelContentTypes the content types to search on. 274 * @param values the user search values. 275 * @param criteria the list of search criteria 276 * @param searchMode The search mode (advanced or simple) 277 * @return the content type {@link Query}. 278 */ 279 protected Query createContentTypeQuery(Set<String> modelContentTypes, Map<String, Object> values, Map<String, SearchUICriterion> criteria, String searchMode) 280 { 281 return createContentTypeQuery(modelContentTypes, values, criteria, searchMode, false); 282 } 283 284 /** 285 * Create a content type query. 286 * @param modelContentTypes the content types to search on. 287 * @param values the user search values. 288 * @param criteria the list of search criteria 289 * @param searchMode The search mode (advanced or simple) 290 * @param exclude <code>true</code> to create a negative query ("all but the given content types"), <code>false</code> to create a standard "include" query. 291 * @return the content type {@link Query}. 292 */ 293 @SuppressWarnings("unchecked") 294 protected Query createContentTypeQuery(Set<String> modelContentTypes, Map<String, Object> values, Map<String, SearchUICriterion> criteria, String searchMode, boolean exclude) 295 { 296 String systemCriterionId = _findSystemCriterionId("contentTypes", criteria); 297 Object cTypeParam = _getAndRemoveValueForSytemCriterion(systemCriterionId, values, searchMode); 298 299 boolean criterionIsMultiple = Optional.ofNullable(systemCriterionId).map(criteria::get).map(SearchUICriterion::isMultiple).orElse(false); 300 boolean emptyCTypeParam = criterionIsMultiple && CollectionUtils.isEmpty((Collection< ? >) cTypeParam) || !criterionIsMultiple && StringUtils.isEmpty((String) cTypeParam); 301 Operator op = exclude ? Operator.NE : Operator.EQ; 302 303 if (emptyCTypeParam) 304 { 305 return createContentTypeOrMixinQuery(modelContentTypes, values, criteria, searchMode); 306 } 307 else if (criterionIsMultiple) // non-empty and multiple 308 { 309 return new ContentTypeQuery(op, (Collection<String>) cTypeParam); 310 } 311 else // non-empty and non-multiple 312 { 313 return new ContentTypeQuery(op, (String) cTypeParam); 314 } 315 } 316 317 /** 318 * Create a mixin type query. 319 * @param mixinTypes the mixin types to search on. 320 * @param values the user search values. 321 * @param criteria the list of search criteria 322 * @param searchMode The search mode (advanced or simple) 323 * @return the mixin type {@link Query}. 324 */ 325 @SuppressWarnings("unchecked") 326 protected Query createMixinTypeQuery(Set<String> mixinTypes, Map<String, Object> values, Map<String, SearchUICriterion> criteria, String searchMode) 327 { 328 String systemCriterionId = _findSystemCriterionId("mixin", criteria); 329 Object mixinParam = _getAndRemoveValueForSytemCriterion(systemCriterionId, values, searchMode); 330 331 boolean criterionIsMultiple = Optional.ofNullable(systemCriterionId).map(criteria::get).map(SearchUICriterion::isMultiple).orElse(false); 332 boolean emptyMixinParam = criterionIsMultiple && CollectionUtils.isEmpty((Collection< ? >) mixinParam) || !criterionIsMultiple && StringUtils.isEmpty((String) mixinParam); 333 334 if (emptyMixinParam) 335 { 336 return null; 337 } 338 else if (criterionIsMultiple) // non-empty and multiple 339 { 340 return new MixinTypeQuery((Collection<String>) mixinParam); 341 } 342 else // non-empty and non-multiple 343 { 344 return new MixinTypeQuery((String) mixinParam); 345 } 346 } 347 348 /** 349 * Create a content type or mixin query. 350 * @param modelContentTypes the content types or mixins to search on. 351 * @param values the user search values. 352 * @param criteria the list of search criteria 353 * @param searchMode The search mode (advanced or simple) 354 * @return the content type {@link Query}. 355 */ 356 protected Query createContentTypeOrMixinQuery(Set<String> modelContentTypes, Map<String, Object> values, Map<String, SearchUICriterion> criteria, String searchMode) 357 { 358 String systemCriterionId = _findSystemCriterionId("contentTypeOrMixin", criteria); 359 Object cTypeOrMixinParam = _getAndRemoveValueForSytemCriterion(systemCriterionId, values, searchMode); 360 361 boolean criterionIsMultiple = Optional.ofNullable(systemCriterionId).map(criteria::get).map(SearchUICriterion::isMultiple).orElse(false); 362 return createContentTypeOrMixinQuery(modelContentTypes, cTypeOrMixinParam, criterionIsMultiple); 363 } 364 365 /** 366 * Create a content type or mixin query. 367 * @param modelContentTypes the content types or mixins to search on. 368 * @param cTypeOrMixinParam The contentTypes or mixin parameter on which you want to build the Query 369 * @param criterionIsMultiple <code>true</code> if the given parameter is a value for a multiple criterion 370 * @return the content type {@link Query}. 371 */ 372 @SuppressWarnings("unchecked") 373 public Query createContentTypeOrMixinQuery(Set<String> modelContentTypes, Object cTypeOrMixinParam, boolean criterionIsMultiple) 374 { 375 boolean emptyCTypeOrMixinParam = criterionIsMultiple && CollectionUtils.isEmpty((Collection< ? >) cTypeOrMixinParam) || !criterionIsMultiple && StringUtils.isEmpty((String) cTypeOrMixinParam); 376 377 if (emptyCTypeOrMixinParam && modelContentTypes != null && !modelContentTypes.isEmpty()) // empty and non-empty model contentTypes 378 { 379 return _contentTypeQueryOrMixinQuery(modelContentTypes); 380 } 381 else if (emptyCTypeOrMixinParam) // empty and empty model contentTypes 382 { 383 return null; 384 } 385 else if (criterionIsMultiple) // non-empty and multiple 386 { 387 return _contentTypeQueryOrMixinQuery((Collection<String>) cTypeOrMixinParam); 388 } 389 else // non-empty and non-multiple 390 { 391 String cTypeOrMixinParamStr = (String) cTypeOrMixinParam; 392 return _cTypeEP.getExtension(cTypeOrMixinParamStr).isMixin() ? new MixinTypeQuery(cTypeOrMixinParamStr) 393 : new ContentTypeQuery(cTypeOrMixinParamStr); 394 } 395 } 396 397 private Query _contentTypeQueryOrMixinQuery(Collection<String> contentTypes) 398 { 399 Collection<String> onlyMixins = contentTypes.stream().filter(ct -> _cTypeEP.getExtension(ct).isMixin()).collect(Collectors.toList()); 400 Collection<String> onlyContentTypes = contentTypes.stream().filter(ct -> !_cTypeEP.getExtension(ct).isMixin()).collect(Collectors.toList()); 401 if (onlyMixins.isEmpty()) 402 { 403 return new ContentTypeQuery(contentTypes); 404 } 405 else if (onlyContentTypes.isEmpty()) 406 { 407 return new MixinTypeQuery(contentTypes); 408 } 409 else 410 { 411 return new OrQuery(new ContentTypeQuery(onlyContentTypes), new MixinTypeQuery(onlyMixins)); 412 } 413 } 414 415 private Object _getAndRemoveValueForSytemCriterion(String systemCriterionId, Map<String, Object> values, String searchMode) 416 { 417 if (systemCriterionId == null) 418 { 419 return null; 420 } 421 422 if ("simple".equals(searchMode)) 423 { 424 return values.remove(systemCriterionId); 425 } 426 427 // advanced search mode 428 String type = (String) values.get("type"); 429 if ("criterion".equals(type) && systemCriterionId.equals(values.get("id"))) // advanced mode with a unique criterion node 430 { 431 Object result = values.get("value"); 432 values.clear(); 433 return result; 434 } 435 else if (_isFlatAndExpression(type, values)) // advanced mode with an AND operator on only criteria (no complicated nested expressions) 436 { 437 @SuppressWarnings("unchecked") 438 List<Map<String, Object>> expressions = (List<Map<String, Object>>) values.get("expressions"); 439 Map<String, Object> systemPropertyExpr = expressions.stream().filter(exp -> systemCriterionId.equals(exp.get("id"))).findFirst().orElse(null); 440 if (systemPropertyExpr != null) 441 { 442 expressions.remove(systemPropertyExpr); 443 return systemPropertyExpr.get("value"); 444 } 445 } 446 447 return null; 448 } 449 450 private String _findSystemCriterionId(String systemPropertyId, Map<String, SearchUICriterion> criteria) 451 { 452 for (SearchCriterion criterion : criteria.values()) 453 { 454 if (criterion instanceof SystemSearchCriterion && criterion.getOperator() == Operator.EQ) 455 { 456 if (((SystemSearchCriterion) criterion).getSystemPropertyId().equals(systemPropertyId)) 457 { 458 return criterion.getId(); 459 } 460 } 461 } 462 463 return null; 464 } 465 466 @SuppressWarnings("unchecked") 467 private boolean _isFlatAndExpression(String rootType, Map<String, Object> values) 468 { 469 return "and".equalsIgnoreCase(rootType) 470 && ((List<Map<String, Object>>) values.get("expressions")).stream().allMatch(exp -> "criterion".equals(exp.get("type"))); 471 } 472 473 /** 474 * Get the list of query on criteria. 475 * @param criteria the list of search criteria 476 * @param values The submitted values 477 * @param language The query language. 478 * @param contextualParameters The contextual parameters 479 * @return The criteria {@link Query}. 480 */ 481 protected List<Query> getCriteriaQueries(Map<String, SearchUICriterion> criteria, Map<String, Object> values, String language, Map<String, Object> contextualParameters) 482 { 483 List<Query> queries = new ArrayList<>(); 484 485 for (String id : criteria.keySet()) 486 { 487 SearchUICriterion criterion = criteria.get(id); 488 Object submitValue = values.get(id); 489 490 // If the criterion is hidden, take the default value (fixed in the search model). 491 // Otherwise take the standard user value. 492 Object value = criterion.isHidden() ? criterion.getDefaultValue() : submitValue; 493 494 if (value != null || criterion.getOperator() == Operator.EXISTS) 495 { 496 Query query = criterion.getQuery(value, values, language, contextualParameters); 497 if (query != null) 498 { 499 queries.add(query); 500 } 501 } 502 } 503 504 return queries; 505 } 506 507 /** 508 * Get a complex Query from the advanced search values. 509 * @param criteria the list of search criteria 510 * @param values The submitted values 511 * @param language The query language. 512 * @param contextualParameters The contextual parameters 513 * @return The criteria {@link Query}. 514 */ 515 protected Query getAdvancedCriteriaQuery(Map<String, SearchUICriterion> criteria, Map<String, Object> values, String language, Map<String, Object> contextualParameters) 516 { 517 Query query = null; 518 519 String type = (String) values.get("type"); 520 521 if ("criterion".equals(type)) 522 { 523 // Criterion node: return a simple Query object. 524 String id = (String) values.get("id"); 525 Object value = values.get("value"); 526 String op = (String) values.get("op"); 527 528 SearchUICriterion criterion = criteria.get(id); 529 530 query = getAdvancedCriterionQuery(criterion, op, value, language, contextualParameters); 531 } 532 else if ("and".equalsIgnoreCase(type) || "or".equalsIgnoreCase(type)) 533 { 534 // AND/OR node: recurse on each sub-query and return a AndQuery/OrQuery. 535 List<Query> queries = new ArrayList<>(); 536 537 @SuppressWarnings("unchecked") 538 List<Map<String, Object>> expressions = (List<Map<String, Object>>) values.get("expressions"); 539 for (Map<String, Object> expression : expressions) 540 { 541 Query expQuery = getAdvancedCriteriaQuery(criteria, expression, language, contextualParameters); 542 if (expQuery != null) 543 { 544 queries.add(expQuery); 545 } 546 } 547 548 query = "and".equalsIgnoreCase(type) ? new AndQuery(queries) : new OrQuery(queries); 549 } 550 551 return query; 552 } 553 554 /** 555 * Build the {@link Query} object corresponding to an advanced criterion value. 556 * @param criterion The search criterion. 557 * @param op The advanced operator (can be a custom one, such as "contain"). 558 * @param value The user value. 559 * @param language The query language. 560 * @param contextualParameters the search contextual parameters. 561 * @return The criterion {@link Query}. 562 */ 563 protected Query getAdvancedCriterionQuery(SearchUICriterion criterion, String op, Object value, String language, Map<String, Object> contextualParameters) 564 { 565 Query query = null; 566 567 // Special wildcard searches. 568 if ("contains".equals(op) || "not-contains".equals(op)) 569 { 570 String wdValue = "*" + escapeQueryCharsForLikeQuery((String) value, contextualParameters) + "*"; 571 query = criterion.getQuery(wdValue, Operator.LIKE, Collections.emptyMap(), language, contextualParameters); 572 if ("not-contains".equals(op)) 573 { 574 query = new NotQuery(query); 575 } 576 } 577 else if ("starts-with".equals(op) || "not-starts-with".equals(op)) 578 { 579 String wdValue = escapeQueryCharsForLikeQuery((String) value, contextualParameters) + "*"; 580 query = criterion.getQuery(wdValue, Operator.LIKE, Collections.emptyMap(), language, contextualParameters); 581 if ("not-starts-with".equals(op)) 582 { 583 query = new NotQuery(query); 584 } 585 } 586 else if ("ends-with".equals(op) || "not-ends-with".equals(op)) 587 { 588 String wdValue = "*" + escapeQueryCharsForLikeQuery((String) value, contextualParameters); 589 query = criterion.getQuery(wdValue, Operator.LIKE, Collections.emptyMap(), language, contextualParameters); 590 if ("not-ends-with".equals(op)) 591 { 592 query = new NotQuery(query); 593 } 594 } 595 else if (StringUtils.isNotBlank(op)) 596 { 597 Operator operator = Operator.fromName(op); 598 query = criterion.getQuery(value, operator, Collections.emptyMap(), language, contextualParameters); 599 } 600 601 return query; 602 } 603 604 /** 605 * Escape special Solr query characters of the user input, before building the query in order to append '*' character(s) 606 * @param string The string to escape. 607 * @param contextualParameters the search contextual parameters. 608 * @return The escaped string. 609 */ 610 protected String escapeQueryCharsForLikeQuery(String string, Map<String, Object> contextualParameters) 611 { 612 contextualParameters.put(QueryBuilder.VALUE_IS_ESCAPED, Boolean.TRUE); 613 return ClientUtils.escapeQueryChars(string); 614 } 615 616}