001/* 002 * Copyright 2020 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.web.frontoffice.search.requesttime.impl; 017 018import java.util.ArrayList; 019import java.util.Collection; 020import java.util.HashMap; 021import java.util.List; 022import java.util.Map; 023import java.util.Optional; 024import java.util.Set; 025import java.util.function.BiFunction; 026import java.util.function.Function; 027import java.util.function.Predicate; 028import java.util.stream.Collectors; 029 030import org.apache.avalon.framework.component.Component; 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.slf4j.Logger; 037 038import org.ametys.cms.search.advanced.AbstractTreeNode; 039import org.ametys.cms.search.advanced.AdvancedQueryBuilder; 040import org.ametys.cms.search.advanced.TreeLeaf; 041import org.ametys.cms.search.advanced.TreeMaker; 042import org.ametys.cms.search.advanced.utils.TreePrinter; 043import org.ametys.cms.search.query.MatchNoneQuery; 044import org.ametys.cms.search.query.OrQuery; 045import org.ametys.cms.search.query.Query; 046import org.ametys.cms.search.query.Query.Operator; 047import org.ametys.cms.search.query.QuerySyntaxException; 048import org.ametys.core.util.I18nUtils; 049import org.ametys.runtime.i18n.I18nizableText; 050import org.ametys.runtime.parameter.ValidationResult; 051import org.ametys.runtime.parameter.Validator; 052import org.ametys.web.frontoffice.search.instance.SearchServiceInstance; 053import org.ametys.web.frontoffice.search.instance.model.SearchContext; 054import org.ametys.web.frontoffice.search.instance.model.SearchServiceCriterion; 055import org.ametys.web.frontoffice.search.instance.model.SearchServiceCriterionMode; 056import org.ametys.web.frontoffice.search.instance.model.SearchServiceCriterionMode.CriterionWrappedValue; 057import org.ametys.web.frontoffice.search.metamodel.AdditionalParameterValueMap; 058import org.ametys.web.frontoffice.search.metamodel.RestrictedEnumerator.RestrictedValues; 059import org.ametys.web.frontoffice.search.metamodel.Returnable; 060import org.ametys.web.frontoffice.search.metamodel.SearchServiceCriterionDefinition; 061import org.ametys.web.frontoffice.search.metamodel.Searchable; 062import org.ametys.web.frontoffice.search.metamodel.context.ContextQueriesWrapper; 063import org.ametys.web.frontoffice.search.requesttime.SearchComponentArguments; 064import org.ametys.web.frontoffice.search.requesttime.input.SearchUserInputs; 065import org.ametys.web.frontoffice.search.requesttime.input.impl.UserPrefsSearchUserInputs; 066import org.ametys.web.repository.page.SitemapElement; 067import org.ametys.web.repository.site.Site; 068 069import com.google.common.collect.ImmutableMap; 070 071/** 072 * A helper for all search component 073 */ 074public class SearchComponentHelper implements Serviceable, Component 075{ 076 /** The avalon role. */ 077 public static final String ROLE = SearchComponentHelper.class.getName(); 078 079 /** The {@link Query} building to the empty string */ 080 public static final Query EMPTY_QUERY = new Query() 081 { 082 @Override 083 public String build() throws QuerySyntaxException 084 { 085 return StringUtils.EMPTY; 086 } 087 @Override 088 public String toString(int indent) 089 { 090 return StringUtils.repeat(' ', indent) + "EmptyQuery()"; 091 } 092 }; 093 094 /** The builder of advanced queries */ 095 protected AdvancedQueryBuilder _advancedQueryBuilder; 096 097 /** The Advanced tree maker */ 098 protected TreeMaker _treeMaker; 099 100 /** The utils for i18nizable texts */ 101 protected I18nUtils _i18nUtils; 102 103 public void service(ServiceManager manager) throws ServiceException 104 { 105 _advancedQueryBuilder = (AdvancedQueryBuilder) manager.lookup(AdvancedQueryBuilder.ROLE); 106 _treeMaker = (TreeMaker) manager.lookup(TreeMaker.ROLE); 107 _i18nUtils = (I18nUtils) manager.lookup(I18nUtils.ROLE); 108 } 109 110 /** 111 * Get the criterion tree query from arguments 112 * @param args the search arguments 113 * @param checkValidInputs true if inputs needs to be checked 114 * @param logTree true if the criterion tree need to be logged 115 * @return the criterion tree query 116 * @throws InvalidUserInputException if an invalid user input occurred 117 */ 118 public Query getCriterionTreeQuery(SearchComponentArguments args, boolean checkValidInputs, boolean logTree) throws InvalidUserInputException 119 { 120 SearchServiceInstance serviceInstance = args.serviceInstance(); 121 AbstractTreeNode<SearchServiceCriterion<?>> criterionTree = serviceInstance.getCriterionTree().get(); 122 SearchUserInputs userInputs = args.userInputs(); 123 124 Map<String, Object> contextualParameters = getSearchComponentContextualParameters(args); 125 126 Map<String, Object> userCriteria = checkValidInputs 127 ? checkValidInputs(criterionTree, userInputs.criteria(), userInputs instanceof UserPrefsSearchUserInputs, contextualParameters, args.logger()) 128 : userInputs.criteria(); 129 130 if (logTree) 131 { 132 _logTree(args.logger(), criterionTree, userCriteria); 133 } 134 135 return getCriterionTreeQuery(serviceInstance, userCriteria, args.currentSite(), args.currentLang()); 136 } 137 138 /** 139 * Get the criterion tree query 140 * @param serviceInstance the service instance 141 * @param userCriteria the user criteria 142 * @param site the site 143 * @param lang the lang 144 * @return the criterion tree query 145 */ 146 public Query getCriterionTreeQuery(SearchServiceInstance serviceInstance, Map<String, Object> userCriteria, Site site, String lang) 147 { 148 AbstractTreeNode<SearchServiceCriterion<?>> criterionTree = serviceInstance.getCriterionTree().get(); 149 150 Collection<Returnable> returnables = serviceInstance.getReturnables(); 151 Collection<Searchable> searchables = serviceInstance.getSearchables(); 152 AdditionalParameterValueMap additionalParameters = serviceInstance.getAdditionalParameterValues(); 153 Map<String, Object> contextualParameters = _contextualParameters(site); 154 155 List<Query> buildQueries = searchables.stream() 156 .map(s -> s.buildQuery(criterionTree, userCriteria, returnables, searchables, additionalParameters, lang, contextualParameters)) 157 .collect(Collectors.toList()); 158 159 return buildQueries.isEmpty() 160 ? null 161 : buildQueries.size() == 1 ? buildQueries.get(0) : new OrQuery(buildQueries); 162 } 163 164 /** 165 * Checks the user inputs are valid. 166 * @param criterionTree The criterion tree of the service instance 167 * @param userCriteria The user input criteria 168 * @param isFromUserPref <code>true</code> if the criteria are from user pref. Then return only the valid input, otherwise throw an exception 169 * @param contextualParameters the contextual parameters 170 * @param logger The logger 171 * @return the filtered user criteria 172 * @throws InvalidUserInputException if at least one user input is invalid 173 */ 174 protected Map<String, Object> checkValidInputs(AbstractTreeNode<SearchServiceCriterion<?>> criterionTree, Map<String, Object> userCriteria, boolean isFromUserPref, Map<String, Object> contextualParameters, Logger logger) throws InvalidUserInputException 175 { 176 Map<String, Object> filteredUserCriteria = userCriteria; 177 178 List<SearchServiceCriterion> criteria = criterionTree.getFlatLeaves() 179 .stream() 180 .map(TreeLeaf::getValue) 181 .collect(Collectors.toList()); 182 183 // Check user inputs are all declared by the service instance 184 Set<String> criterionIds = criteria.stream() 185 .filter(crit -> !crit.getMode().isStatic()) 186 .map(SearchServiceCriterion::getName) 187 .collect(Collectors.toSet()); 188 189 // If the user input is from user pref, just ignore the invalid input 190 if (isFromUserPref) 191 { 192 filteredUserCriteria = filteredUserCriteria.entrySet() 193 .stream() 194 .filter(c -> this._isFromCriteria(criterionIds, c.getKey(), logger)) 195 .collect(Collectors.toMap(e -> e.getKey(), e -> e.getValue())); 196 } 197 else if (!CollectionUtils.containsAll(criterionIds, filteredUserCriteria.keySet())) 198 { 199 throw new InvalidUserInputException("At least one of the user input criterion is invalid because it was not declared by the service instance."); 200 } 201 202 // Check values are among restricted ones (if so) and validate 203 for (SearchServiceCriterion criterion : criteria) 204 { 205 String criterionId = criterion.getName(); 206 207 if (filteredUserCriteria.containsKey(criterionId)) 208 { 209 SearchServiceCriterionDefinition criterionDefinition = criterion.getCriterionDefinition(); 210 SearchServiceCriterionMode mode = criterion.getMode(); 211 Optional<RestrictedValues> restrictedValues = criterion.getRestrictedValues(); 212 Validator validator = criterionDefinition.getValidator(); 213 Object userCriterionValues = filteredUserCriteria.get(criterionId); 214 try 215 { 216 checkValidInputValues(userCriterionValues, criterionId, criterionDefinition, mode, restrictedValues, validator, contextualParameters); 217 } 218 catch (InvalidUserInputException e) 219 { 220 // If the user input is from user pref, just ignore the invalid input 221 if (isFromUserPref) 222 { 223 filteredUserCriteria.remove(criterionId); 224 if (logger.isDebugEnabled()) 225 { 226 logger.debug("The user input criterion ({}) from user preferences is invalid because of the following error: {}", criterionId, e); 227 } 228 } 229 else 230 { 231 throw e; 232 } 233 } 234 } 235 } 236 237 return filteredUserCriteria; 238 } 239 240 private boolean _isFromCriteria(Set<String> criterionIds, String criterionId, Logger logger) 241 { 242 boolean isFromCriteria = criterionIds.contains(criterionId); 243 if (!isFromCriteria) 244 { 245 if (logger.isDebugEnabled()) 246 { 247 logger.debug("The user input criterion ({}) from user preferences is invalid because it was not declared by the service instance.", criterionId); 248 } 249 } 250 return isFromCriteria; 251 } 252 253 /** 254 * Checks the user inputs for one criterion (can be multiple) are valid 255 * @param userCriterionValues The multiple user values (then it is a List) or the single user value 256 * @param criterionId The criterion id 257 * @param criterionDefinition the criterion definition 258 * @param mode The criterion mode 259 * @param optionalRestrictedValues The {@link RestrictedValues} if mode is {@link SearchServiceCriterionMode#RESTRICTED_USER_INPUT} 260 * @param validator The criterion validator 261 * @param contextualParameters The contextual parameters 262 * @throws InvalidUserInputException if at least one user input is invalid 263 */ 264 protected void checkValidInputValues(Object userCriterionValues, String criterionId, SearchServiceCriterionDefinition criterionDefinition, SearchServiceCriterionMode mode, Optional<RestrictedValues> optionalRestrictedValues, Validator validator, Map<String, Object> contextualParameters) throws InvalidUserInputException 265 { 266 if (userCriterionValues instanceof List<?>) 267 { 268 List<?> userCriterionMultipleValues = (List<?>) userCriterionValues; 269 for (Object userCriterionSingleValue : userCriterionMultipleValues) 270 { 271 checkValidInputSingleValue(convertUserCriterionValue(userCriterionSingleValue, criterionDefinition, contextualParameters), criterionId, mode, optionalRestrictedValues, validator); 272 } 273 } 274 else 275 { 276 checkValidInputSingleValue(convertUserCriterionValue(userCriterionValues, criterionDefinition, contextualParameters), criterionId, mode, optionalRestrictedValues, validator); 277 } 278 } 279 280 /** 281 * Converts the given user criterion value 282 * @param userCriterionValue The single user value 283 * @param criterionDefinition the criterion definition 284 * @param contextualParameters The contextual parameters 285 * @return the converted user value 286 */ 287 @SuppressWarnings("unchecked") 288 protected Object convertUserCriterionValue(Object userCriterionValue, SearchServiceCriterionDefinition criterionDefinition, Map<String, Object> contextualParameters) 289 { 290 return SearchServiceCriterionMode.NONE_VALUE.equals(userCriterionValue) 291 ? userCriterionValue 292 : criterionDefinition.convertRestrictedValue(userCriterionValue, contextualParameters); 293 } 294 295 296 /** 297 * Checks a single value of one user input for one criterion is valid 298 * @param userCriterionSingleValue The single user value 299 * @param criterionId The criterion id 300 * @param mode The criterion mode 301 * @param optionalRestrictedValues The {@link RestrictedValues} if mode is {@link SearchServiceCriterionMode#RESTRICTED_USER_INPUT} 302 * @param validator The criterion validator 303 * @throws InvalidUserInputException if the user input is invalid 304 */ 305 protected void checkValidInputSingleValue(Object userCriterionSingleValue, String criterionId, SearchServiceCriterionMode mode, Optional<RestrictedValues> optionalRestrictedValues, Validator validator) throws InvalidUserInputException 306 { 307 if (mode == SearchServiceCriterionMode.RESTRICTED_USER_INPUT) 308 { 309 try 310 { 311 Set<Object> restrictedValues = optionalRestrictedValues.get().values().keySet(); 312 313 if (!restrictedValues.contains(userCriterionSingleValue) 314 && (!SearchServiceCriterionMode.NONE_VALUE.equals(userCriterionSingleValue) || _isMandatory(validator))) 315 { 316 throw new InvalidUserInputException("The user input criterion '" + userCriterionSingleValue + "' for criterion '" + criterionId + "' is invalid because it is not among declared restricted values (" + restrictedValues + ")."); 317 } 318 } 319 catch (Exception e) 320 { 321 // An error occurred while retrieving restricted values 322 throw new IllegalStateException("An unexpected error occured. Unable to compute restricted values for criterion '" + criterionId + "'", e); 323 } 324 } 325 326 List<I18nizableText> errors = Optional.ofNullable(validator) 327 .map(val -> val.validate(userCriterionSingleValue)) 328 .filter(ValidationResult::hasErrors) 329 .map(ValidationResult::getErrors) 330 .orElseGet(ArrayList::new); 331 if (!errors.isEmpty()) 332 { 333 String translatedErrors = errors.stream() 334 .map(_i18nUtils::translate) 335 .collect(Collectors.joining("\n")); 336 throw new InvalidUserInputException("The user input '" + userCriterionSingleValue + "' for criterion '" + criterionId + "' is invalid because of the following errors:\n" + translatedErrors); 337 } 338 } 339 340 private boolean _isMandatory(Validator validator) 341 { 342 return validator != null 343 ? validator.getConfiguration().containsKey("mandatory") && (Boolean) validator.getConfiguration().get("mandatory") 344 : false; 345 } 346 347 private Map<String, Object> _contextualParameters(Site currentSite) 348 { 349 return new HashMap<>(ImmutableMap.of("siteName", currentSite.getName())); 350 } 351 352 /** 353 * Builds the query of the criterion tree 354 * @param criterionTree The criterion tree of the service instance 355 * @param userCriteria The user input criteria 356 * @param returnables The returnables of the service instance 357 * @param searchables The searchables of the service instance 358 * @param additionalParameters The values of additional parameters of the service instance 359 * @param currentLang The current lang 360 * @param filterCriterionPredicate A function to filter criterion. Can be null for TruePredicate 361 * @param contextualParameters the search contextual parameters. 362 * @return The query of the criterion tree 363 */ 364 public Query buildQuery( 365 AbstractTreeNode<SearchServiceCriterion<?>> criterionTree, 366 Map<String, Object> userCriteria, 367 Collection<Returnable> returnables, 368 Collection<Searchable> searchables, 369 AdditionalParameterValueMap additionalParameters, 370 String currentLang, 371 Predicate<SearchServiceCriterion> filterCriterionPredicate, 372 Map<String, Object> contextualParameters) 373 { 374 Predicate<SearchServiceCriterion> predicate = filterCriterionPredicate != null ? filterCriterionPredicate : crit -> true; 375 Function<SearchServiceCriterion<?>, Query> queryMapper = crit -> singleCriterionToQuery(crit, userCriteria, returnables, searchables, additionalParameters, currentLang, predicate, contextualParameters); 376 return _advancedQueryBuilder.build(criterionTree, queryMapper); 377 } 378 379 /** 380 * Builds the query of the single criterion 381 * @param criterion The criterion 382 * @param userCriteria The user input criteria 383 * @param returnables The returnables of the service instance 384 * @param searchables The searchables of the service instance 385 * @param additionalParameters The values of additional parameters of the service instance 386 * @param currentLang The current lang 387 * @param filterCriterionPredicate A function to filter criterion 388 * @param contextualParameters the search contextual parameters. 389 * @param <T> Type of the criterion value 390 * @return The query of the single criterion 391 */ 392 public <T> Query singleCriterionToQuery( 393 SearchServiceCriterion<T> criterion, 394 Map<String, Object> userCriteria, 395 Collection<Returnable> returnables, 396 Collection<Searchable> searchables, 397 AdditionalParameterValueMap additionalParameters, 398 String currentLang, 399 Predicate<SearchServiceCriterion> filterCriterionPredicate, 400 Map<String, Object> contextualParameters) 401 { 402 SearchServiceCriterionMode mode = criterion.getMode(); 403 CriterionWrappedValue val = mode.getValue(criterion, userCriteria, contextualParameters); 404 405 // The criterion was not filled by the visitor or it is filtered 406 // Put an empty query. It will be ignored by And/OrQuery 407 Query joinedQuery; 408 if (val.getValue() == null || !filterCriterionPredicate.test(criterion)) 409 { 410 joinedQuery = EMPTY_QUERY; 411 } 412 else 413 { 414 BiFunction<CriterionWrappedValue, Operator, Query> queryFunctionFromTransformedValAndRealOperator = (transformedVal, realOperator) -> _queryFromTransformedValAndRealOperator( 415 criterion.getCriterionDefinition(), 416 transformedVal, 417 realOperator, 418 returnables, 419 searchables, 420 additionalParameters, 421 currentLang, 422 contextualParameters); 423 424 joinedQuery = _treeMaker.toQuery(val, criterion.getOperator(), queryFunctionFromTransformedValAndRealOperator, currentLang, contextualParameters); 425 } 426 427 return Optional.ofNullable(joinedQuery).orElse(new MatchNoneQuery()); 428 } 429 430 private <T> Query _queryFromTransformedValAndRealOperator( 431 SearchServiceCriterionDefinition<T> criterionDefinition, 432 CriterionWrappedValue transformedVal, 433 Operator realOperator, 434 Collection<Returnable> returnables, 435 Collection<Searchable> searchables, 436 AdditionalParameterValueMap additionalParameters, 437 String currentLang, 438 Map<String, Object> contextualParameters) 439 { 440 Query queryOnCriterion = _queryOnCriterion(criterionDefinition, transformedVal, realOperator, currentLang, contextualParameters); 441 Optional<Searchable> searchable = criterionDefinition.getSearchable(); 442 Query joinedQuery; 443 444 if (searchable.isPresent()) 445 { 446 joinedQuery = searchable.get().joinQuery(queryOnCriterion, criterionDefinition, returnables, additionalParameters).orElse(null); 447 } 448 else 449 { 450 // common => all searchables are concerned 451 List<Query> joinedQueries = searchables.stream() 452 .map(s -> s.joinQuery(queryOnCriterion, criterionDefinition, returnables, additionalParameters)) 453 .flatMap(Optional::stream) 454 .toList(); 455 joinedQuery = joinedQueries.isEmpty() ? null : new OrQuery(joinedQueries); 456 } 457 return joinedQuery; 458 } 459 460 private <T> Query _queryOnCriterion( 461 SearchServiceCriterionDefinition<T> criterionDefinition, 462 CriterionWrappedValue transformedVal, 463 Operator realOperator, 464 String currentLang, 465 Map<String, Object> contextualParameters) 466 { 467 Object unwrappedValue = transformedVal.getValue(); 468 if (transformedVal.requestEmptyValue()) 469 { 470 return transformedVal.hasValue() 471 ? new OrQuery(criterionDefinition.getEmptyValueQuery(currentLang, contextualParameters), criterionDefinition.getQuery(unwrappedValue, realOperator, currentLang, contextualParameters)) 472 : criterionDefinition.getEmptyValueQuery(currentLang, contextualParameters); 473 } 474 else 475 { 476 return criterionDefinition.getQuery(unwrappedValue, realOperator, currentLang, contextualParameters); 477 } 478 } 479 480 private void _logTree(Logger logger, AbstractTreeNode<SearchServiceCriterion<?>> tree, Map<String, Object> userCriteria) 481 { 482 if (logger.isDebugEnabled()) 483 { 484 Function<SearchServiceCriterion<?>, String> leafStringifier = c -> 485 { 486 String id = c.getName(); 487 String value = String.valueOf(userCriteria.get(id)); 488 return "{" + id + ": Operator=" + c.getOperator() + ", Mode=" + c.getMode() + ", Value=" + value + ", StaticValue=" + c.getStaticValue() + "}"; 489 }; 490 logger.debug("\n" + TreePrinter.print(tree, leafStringifier)); 491 } 492 } 493 494 /** 495 * Get the filter query from arguments 496 * @param args the search arguments 497 * @return the filter query 498 */ 499 public Query getFilterQuery(SearchComponentArguments args) 500 { 501 SitemapElement currentPage = args.currentPage(); 502 Site currentSite = args.currentSite(); 503 String currentLang = args.currentLang(); 504 SearchServiceInstance serviceInstance = args.serviceInstance(); 505 506 return getFilterQuery(serviceInstance, currentSite, currentPage, currentLang); 507 } 508 509 /** 510 * Get the filter query 511 * @param serviceInstance the service instance 512 * @param site the site 513 * @param page the page 514 * @param lang the lang 515 * @return the filter query 516 */ 517 public Query getFilterQuery(SearchServiceInstance serviceInstance, Site site, SitemapElement page, String lang) 518 { 519 AdditionalParameterValueMap additionalParameterValues = serviceInstance.getAdditionalParameterValues(); 520 521 List<ContextQueriesWrapper> contextQueriesWrappers = serviceInstance.getContexts() 522 .stream() 523 .map(searchCtx -> _createContextQueriesWrapper(searchCtx, site, page, lang)) 524 .collect(Collectors.toList()); 525 526 return serviceInstance.getReturnables() 527 .stream() 528 .map(returnable -> returnable.filterReturnedDocumentQuery(contextQueriesWrappers, additionalParameterValues)) 529 .collect(OrQuery.collector()); 530 } 531 532 /** 533 * Creates a {@link ContextQueriesWrapper} given one {@link SearchContext} and the current site, page and lang. 534 * @param searchContext The search context 535 * @param currentSite The current site 536 * @param currentPage The current page 537 * @param currentLang The current lang 538 * @return The created wrapper of queries of a {@link SearchContext} 539 */ 540 protected ContextQueriesWrapper _createContextQueriesWrapper(SearchContext searchContext, Site currentSite, SitemapElement currentPage, String currentLang) 541 { 542 return new ContextQueriesWrapper( 543 searchContext.getSiteQuery(currentSite), 544 searchContext.getSitemapQuery(currentPage), 545 searchContext.getContextLang(currentLang), 546 searchContext.getTagQuery() 547 ); 548 } 549 550 /** 551 * Retrieves a {@link Map} of contextual parameters 552 * @param args the search component arguments 553 * @return the {@link Map} of contextual parameters 554 */ 555 public static Map<String, Object> getSearchComponentContextualParameters(SearchComponentArguments args) 556 { 557 Map<String, Object> contextualParameters = new HashMap<>(); 558 559 contextualParameters.put("searchContexts", args.serviceInstance().getContexts()); 560 contextualParameters.put("siteName", args.currentSite().getName()); 561 contextualParameters.put("lang", args.currentLang()); 562 563 return contextualParameters; 564 } 565}