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