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