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 @SuppressWarnings("unchecked") 294 protected Query createContentTypeQuery(Set<String> modelContentTypes, Map<String, Object> values, Map<String, SearchCriterion> criteria, String searchMode) 295 { 296 Operator op = Operator.EQ; 297 String systemCriterionId = _findSystemCriterionId("contentTypes", criteria); 298 Object cTypeParam = _getAndRemoveValueForSytemCriterionAndEqOperator(systemCriterionId, values, searchMode); 299 300 boolean criterionIsMultiple = Optional.ofNullable(systemCriterionId) 301 .map(criteria::get) 302 .map(SearchCriterion::isMultiple) 303 .orElse(false); 304 boolean emptyCTypeParam = criterionIsMultiple && CollectionUtils.isEmpty((Collection< ? >) cTypeParam) || !criterionIsMultiple && StringUtils.isEmpty((String) cTypeParam); 305 306 if (emptyCTypeParam) 307 { 308 return createContentTypeOrMixinQuery(modelContentTypes, values, criteria, searchMode); 309 } 310 else if (criterionIsMultiple) // non-empty and multiple 311 { 312 return new ContentTypeQuery(op, (Collection<String>) cTypeParam); 313 } 314 else // non-empty and non-multiple 315 { 316 return new ContentTypeQuery(op, (String) cTypeParam); 317 } 318 } 319 320 /** 321 * Create a mixin type query. 322 * @param mixinTypes the mixin types to search on. 323 * @param values the user search values. 324 * @param criteria the list of search criteria 325 * @param searchMode The search mode (advanced or simple) 326 * @return the mixin type {@link Query}. 327 */ 328 @SuppressWarnings("unchecked") 329 protected Query createMixinTypeQuery(Set<String> mixinTypes, Map<String, Object> values, Map<String, SearchCriterion> criteria, String searchMode) 330 { 331 String systemCriterionId = _findSystemCriterionId("mixin", criteria); 332 Object mixinParam = _getAndRemoveValueForSytemCriterionAndEqOperator(systemCriterionId, values, searchMode); 333 334 boolean criterionIsMultiple = Optional.ofNullable(systemCriterionId) 335 .map(criteria::get) 336 .map(SearchCriterion::isMultiple) 337 .orElse(false); 338 boolean emptyMixinParam = criterionIsMultiple && CollectionUtils.isEmpty((Collection< ? >) mixinParam) || !criterionIsMultiple && StringUtils.isEmpty((String) mixinParam); 339 340 if (emptyMixinParam) 341 { 342 return null; 343 } 344 else if (criterionIsMultiple) // non-empty and multiple 345 { 346 return new MixinTypeQuery((Collection<String>) mixinParam); 347 } 348 else // non-empty and non-multiple 349 { 350 return new MixinTypeQuery((String) mixinParam); 351 } 352 } 353 354 /** 355 * Create a content type or mixin query. 356 * @param modelContentTypes the content types or mixins to search on. 357 * @param values the user search values. 358 * @param criteria the list of search criteria 359 * @param searchMode The search mode (advanced or simple) 360 * @return the content type {@link Query}. 361 */ 362 protected Query createContentTypeOrMixinQuery(Set<String> modelContentTypes, Map<String, Object> values, Map<String, SearchCriterion> criteria, String searchMode) 363 { 364 String systemCriterionId = _findSystemCriterionId("contentTypeOrMixin", criteria); 365 Object cTypeOrMixinParam = _getAndRemoveValueForSytemCriterionAndEqOperator(systemCriterionId, values, searchMode); 366 367 boolean criterionIsMultiple = Optional.ofNullable(systemCriterionId) 368 .map(criteria::get) 369 .map(SearchCriterion::isMultiple) 370 .orElse(false); 371 return createContentTypeOrMixinQuery(modelContentTypes, cTypeOrMixinParam, criterionIsMultiple); 372 } 373 374 /** 375 * Create a content type or mixin query. 376 * @param modelContentTypes the content types or mixins to search on. 377 * @param cTypeOrMixinParam The contentTypes or mixin parameter on which you want to build the Query 378 * @param criterionIsMultiple <code>true</code> if the given parameter is a value for a multiple criterion 379 * @return the content type {@link Query}. 380 */ 381 @SuppressWarnings("unchecked") 382 public Query createContentTypeOrMixinQuery(Set<String> modelContentTypes, Object cTypeOrMixinParam, boolean criterionIsMultiple) 383 { 384 boolean emptyCTypeOrMixinParam = criterionIsMultiple && CollectionUtils.isEmpty((Collection< ? >) cTypeOrMixinParam) || !criterionIsMultiple && StringUtils.isEmpty((String) cTypeOrMixinParam); 385 386 if (emptyCTypeOrMixinParam && modelContentTypes != null && !modelContentTypes.isEmpty()) // empty and non-empty model contentTypes 387 { 388 return _contentTypeQueryOrMixinQuery(modelContentTypes); 389 } 390 else if (emptyCTypeOrMixinParam) // empty and empty model contentTypes 391 { 392 return null; 393 } 394 else if (criterionIsMultiple) // non-empty and multiple 395 { 396 return _contentTypeQueryOrMixinQuery((Collection<String>) cTypeOrMixinParam); 397 } 398 else // non-empty and non-multiple 399 { 400 String cTypeOrMixinParamStr = (String) cTypeOrMixinParam; 401 return _cTypeEP.getExtension(cTypeOrMixinParamStr).isMixin() ? new MixinTypeQuery(cTypeOrMixinParamStr) 402 : new ContentTypeQuery(cTypeOrMixinParamStr); 403 } 404 } 405 406 private Query _contentTypeQueryOrMixinQuery(Collection<String> contentTypes) 407 { 408 Collection<String> onlyMixins = contentTypes.stream() 409 .filter(ct -> _cTypeEP.getExtension(ct).isMixin()) 410 .collect(Collectors.toList()); 411 Collection<String> onlyContentTypes = contentTypes.stream() 412 .filter(ct -> !_cTypeEP.getExtension(ct).isMixin()) 413 .collect(Collectors.toList()); 414 if (onlyMixins.isEmpty()) 415 { 416 return new ContentTypeQuery(contentTypes); 417 } 418 else if (onlyContentTypes.isEmpty()) 419 { 420 return new MixinTypeQuery(contentTypes); 421 } 422 else 423 { 424 return new OrQuery(new ContentTypeQuery(onlyContentTypes), new MixinTypeQuery(onlyMixins)); 425 } 426 } 427 428 private Object _getAndRemoveValueForSytemCriterionAndEqOperator(String systemCriterionId, Map<String, Object> values, String searchMode) 429 { 430 if (systemCriterionId == null) 431 { 432 return null; 433 } 434 435 if ("simple".equals(searchMode)) 436 { 437 return values.remove(systemCriterionId); 438 } 439 440 // advanced search mode 441 String type = (String) values.get("type"); 442 final String eqOperator = Operator.EQ.getName(); 443 if ("criterion".equals(type) && systemCriterionId.equals(values.get("id")) && eqOperator.equals(values.get("op"))) // 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() 454 .filter(exp -> systemCriterionId.equals(exp.get("id"))) 455 .filter(exp -> eqOperator.equals(exp.get("op"))) 456 .findFirst() 457 .orElse(null); 458 if (systemPropertyExpr != null) 459 { 460 expressions.remove(systemPropertyExpr); 461 return systemPropertyExpr.get("value"); 462 } 463 } 464 465 return null; 466 } 467 468 private String _findSystemCriterionId(String systemPropertyId, Map<String, SearchCriterion> criteria) 469 { 470 for (SearchCriterion criterion : criteria.values()) 471 { 472 if (criterion instanceof SystemSearchCriterion && criterion.getOperator() == Operator.EQ) 473 { 474 SystemSearchCriterion systemCrit = (SystemSearchCriterion) criterion; 475 if (!systemCrit.isJoined() && systemCrit.getSystemPropertyId().equals(systemPropertyId)) 476 { 477 return criterion.getId(); 478 } 479 } 480 } 481 482 return null; 483 } 484 485 @SuppressWarnings("unchecked") 486 private boolean _isFlatAndExpression(String rootType, Map<String, Object> values) 487 { 488 return "and".equalsIgnoreCase(rootType) 489 && ((List<Map<String, Object>>) values.get("expressions")) 490 .stream() 491 .allMatch(exp -> "criterion".equals(exp.get("type"))); 492 } 493 494 /** 495 * Get the list of query on criteria. 496 * @param criteria the list of search criteria 497 * @param values The submitted values 498 * @param language The query language. 499 * @param contextualParameters The contextual parameters 500 * @return The criteria {@link Query}. 501 */ 502 protected List<Query> getCriteriaQueries(Map<String, SearchUICriterion> criteria, Map<String, Object> values, String language, Map<String, Object> contextualParameters) 503 { 504 List<Query> queries = new ArrayList<>(); 505 506 for (String id : criteria.keySet()) 507 { 508 SearchUICriterion criterion = criteria.get(id); 509 Object submitValue = values.get(id); 510 511 // If the criterion is hidden, take the default value (fixed in the search model). 512 // Otherwise take the standard user value. 513 Object value = criterion.isHidden() ? criterion.getDefaultValue() : submitValue; 514 515 if (value != null || criterion.getOperator() == Operator.EXISTS) 516 { 517 Query query = criterion.getQuery(value, values, language, contextualParameters); 518 if (query != null) 519 { 520 queries.add(query); 521 } 522 } 523 } 524 525 return queries; 526 } 527 528 /** 529 * Get a complex Query from the advanced search values. 530 * @param criteria the list of search criteria 531 * @param values The submitted values 532 * @param language The query language. 533 * @param contextualParameters The contextual parameters 534 * @return The criteria {@link Query}. 535 */ 536 protected Query getAdvancedCriteriaQuery(Map<String, SearchCriterion> criteria, Map<String, Object> values, String language, Map<String, Object> contextualParameters) 537 { 538 AbstractTreeNode<ValuedSearchCriterion> tree = _createTreeNode(criteria, values); 539 if (tree == null) 540 { 541 return null; 542 } 543 544 if (getLogger().isDebugEnabled()) 545 { 546 getLogger().debug("\n" + TreePrinter.print(tree, c -> "{" + c._searchCriterion.getId() + ": Operator=" + c._op + ", Value=" + c._value + "}")); 547 } 548 return _advancedQueryBuilder.build(tree, valuedCrit -> valuedCrit.toQuery(language, contextualParameters)); 549 } 550 551 private AbstractTreeNode<ValuedSearchCriterion> _createTreeNode(Map<String, SearchCriterion> criteria, Map<String, Object> values) 552 { 553 return _advancedTreeMaker.create(values, clientSideCrit -> new ValuedSearchCriterion(criteria.get(clientSideCrit.getId()), clientSideCrit, _advancedTreeMaker)); 554 } 555 556 private static final class ValuedSearchCriterion 557 { 558 SearchCriterion _searchCriterion; 559 String _op; 560 Object _value; 561 private TreeMaker _advancedTreeMaker; 562 563 ValuedSearchCriterion(SearchCriterion searchCriterion, ClientSideCriterionWrapper clientSideCriterion, TreeMaker advancedTreeMaker) 564 { 565 _searchCriterion = searchCriterion; 566 _op = clientSideCriterion.getStringOperator(); 567 _value = clientSideCriterion.getValue(); 568 _advancedTreeMaker = advancedTreeMaker; 569 } 570 571 Query toQuery(String language, Map<String, Object> contextualParameters) 572 { 573 BiFunction<Object, Operator, Query> toQuery = (transformedVal, realOperator) -> _searchCriterion.getQuery(transformedVal, realOperator, Collections.emptyMap(), language, contextualParameters); 574 return _advancedTreeMaker.toQuery(_value, _op, toQuery, language, contextualParameters); 575 } 576 } 577 578}