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