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