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