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.FOSearchCriterion; 054import org.ametys.web.frontoffice.search.instance.model.FOSearchCriterionMode; 055import org.ametys.web.frontoffice.search.instance.model.FOSearchCriterionMode.FoWrappedValue; 056import org.ametys.web.frontoffice.search.instance.model.SearchContext; 057import org.ametys.web.frontoffice.search.metamodel.AdditionalParameterValueMap; 058import org.ametys.web.frontoffice.search.metamodel.EnumeratedValues.RestrictedValues; 059import org.ametys.web.frontoffice.search.metamodel.Returnable; 060import org.ametys.web.frontoffice.search.metamodel.SearchCriterionDefinition; 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<FOSearchCriterion> 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<FOSearchCriterion> 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<FOSearchCriterion> criterionTree, Map<String, Object> userCriteria, boolean isFromUserPref, Logger logger) throws InvalidUserInputException 171 { 172 Map<String, Object> filteredUserCriteria = userCriteria; 173 174 List<FOSearchCriterion> 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(FOSearchCriterion::getId) 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 (FOSearchCriterion criterion : criteria) 200 { 201 String criterionId = criterion.getId(); 202 203 if (filteredUserCriteria.containsKey(criterionId)) 204 { 205 FOSearchCriterionMode mode = criterion.getMode(); 206 Optional<RestrictedValues> restrictedValues = criterion.getRestrictedValues(); 207 boolean isMandatory = criterion.isMandatory(); 208 Validator validator = criterion.getCriterionDefinition().getValidator(); 209 Object userCriterionValues = filteredUserCriteria.get(criterionId); 210 try 211 { 212 checkValidInputValues(userCriterionValues, criterionId, mode, restrictedValues, validator, isMandatory); 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 mode The criterion mode 254 * @param optionalRestrictedValues The {@link RestrictedValues} if mode is {@link FOSearchCriterionMode#RESTRICTED_USER_INPUT} 255 * @param validator The criterion validator 256 * @param isMandatory true if the criterion is linked to a mandatory data 257 * @throws InvalidUserInputException if at least one user input is invalid 258 */ 259 protected void checkValidInputValues(Object userCriterionValues, String criterionId, FOSearchCriterionMode mode, Optional<RestrictedValues> optionalRestrictedValues, Validator validator, boolean isMandatory) throws InvalidUserInputException 260 { 261 if (userCriterionValues instanceof List<?>) 262 { 263 List<?> userCriterionMultipleValues = (List<?>) userCriterionValues; 264 for (Object userCriterionSingleValue : userCriterionMultipleValues) 265 { 266 checkValidInputSingleValue(userCriterionSingleValue, criterionId, mode, optionalRestrictedValues, validator, isMandatory); 267 } 268 } 269 else 270 { 271 checkValidInputSingleValue(userCriterionValues, criterionId, mode, optionalRestrictedValues, validator, isMandatory); 272 } 273 } 274 275 /** 276 * Checks a single value of one user input for one criterion is valid 277 * @param userCriterionSingleValue The single user value 278 * @param criterionId The criterion id 279 * @param mode The criterion mode 280 * @param optionalRestrictedValues The {@link RestrictedValues} if mode is {@link FOSearchCriterionMode#RESTRICTED_USER_INPUT} 281 * @param validator The criterion validator 282 * @param isMandatory true if the criterion is linked to a mandatory data 283 * @throws InvalidUserInputException if the user input is invalid 284 */ 285 protected void checkValidInputSingleValue(Object userCriterionSingleValue, String criterionId, FOSearchCriterionMode mode, Optional<RestrictedValues> optionalRestrictedValues, Validator validator, boolean isMandatory) throws InvalidUserInputException 286 { 287 if (mode == FOSearchCriterionMode.RESTRICTED_USER_INPUT) 288 { 289 Set<Object> restrictedValues = optionalRestrictedValues.get().values().keySet(); 290 291 if (!restrictedValues.contains(userCriterionSingleValue) 292 && (!FOSearchCriterionMode.NONE_VALUE.equals(userCriterionSingleValue) || isMandatory)) 293 { 294 throw new InvalidUserInputException("The user input criterion '" + userCriterionSingleValue + "' for criterion '" + criterionId + "' is invalid because it is not among declared restricted values (" + restrictedValues + ")."); 295 } 296 } 297 298 List<I18nizableText> errors = Optional.ofNullable(validator) 299 .map(val -> val.validate(userCriterionSingleValue)) 300 .filter(ValidationResult::hasErrors) 301 .map(ValidationResult::getErrors) 302 .orElseGet(ArrayList::new); 303 if (!errors.isEmpty()) 304 { 305 String translatedErrors = errors.stream() 306 .map(_i18nUtils::translate) 307 .collect(Collectors.joining("\n")); 308 throw new InvalidUserInputException("The user input '" + userCriterionSingleValue + "' for criterion '" + criterionId + "' is invalid because of the following errors:\n" + translatedErrors); 309 } 310 } 311 312 private Map<String, Object> _contextualParameters(Site currentSite) 313 { 314 return new HashMap<>(ImmutableMap.of("siteName", currentSite.getName())); 315 } 316 317 /** 318 * Builds the query of the criterion tree 319 * @param criterionTree The criterion tree of the service instance 320 * @param userCriteria The user input criteria 321 * @param returnables The returnables of the service instance 322 * @param searchables The searchables of the service instance 323 * @param additionalParameters The values of additional parameters of the service instance 324 * @param currentLang The current lang 325 * @param filterCriterionPredicate A function to filter criterion. Can be null for TruePredicate 326 * @param contextualParameters the search contextual parameters. 327 * @return The query of the criterion tree 328 */ 329 public Query buildQuery( 330 AbstractTreeNode<FOSearchCriterion> criterionTree, 331 Map<String, Object> userCriteria, 332 Collection<Returnable> returnables, 333 Collection<Searchable> searchables, 334 AdditionalParameterValueMap additionalParameters, 335 String currentLang, 336 Predicate<FOSearchCriterion> filterCriterionPredicate, 337 Map<String, Object> contextualParameters) 338 { 339 Predicate<FOSearchCriterion> predicate = filterCriterionPredicate != null ? filterCriterionPredicate : crit -> true; 340 Function<FOSearchCriterion, Query> queryMapper = crit -> singleCriterionToQuery(crit, userCriteria, returnables, searchables, additionalParameters, currentLang, predicate, contextualParameters); 341 return _advancedQueryBuilder.build(criterionTree, queryMapper); 342 } 343 344 /** 345 * Builds the query of the single criterion 346 * @param searchCriterion The criterion 347 * @param userCriteria The user input criteria 348 * @param returnables The returnables of the service instance 349 * @param searchables The searchables of the service instance 350 * @param additionalParameters The values of additional parameters of the service instance 351 * @param currentLang The current lang 352 * @param filterCriterionPredicate A function to filter criterion 353 * @param contextualParameters the search contextual parameters. 354 * @return The query of the single criterion 355 */ 356 public Query singleCriterionToQuery( 357 FOSearchCriterion searchCriterion, 358 Map<String, Object> userCriteria, 359 Collection<Returnable> returnables, 360 Collection<Searchable> searchables, 361 AdditionalParameterValueMap additionalParameters, 362 String currentLang, 363 Predicate<FOSearchCriterion> filterCriterionPredicate, 364 Map<String, Object> contextualParameters) 365 { 366 FOSearchCriterionMode mode = searchCriterion.getMode(); 367 FoWrappedValue val = mode.getValue(searchCriterion, userCriteria, contextualParameters); 368 369 // The criterion was not filled by the visitor or it is filtered 370 // Put an empty query. It will be ignored by And/OrQuery 371 Query joinedQuery; 372 if (val.getValue() == null || !filterCriterionPredicate.test(searchCriterion)) 373 { 374 joinedQuery = EMPTY_QUERY; 375 } 376 else 377 { 378 BiFunction<FoWrappedValue, Operator, Query> queryFunctionFromTransformedValAndRealOperator = (transformedVal, realOperator) -> _queryFromTransformedValAndRealOperator( 379 searchCriterion.getCriterionDefinition(), 380 transformedVal, 381 realOperator, 382 returnables, 383 searchables, 384 additionalParameters, 385 currentLang, 386 contextualParameters); 387 388 joinedQuery = _treeMaker.toQuery(val, searchCriterion.getOperator(), queryFunctionFromTransformedValAndRealOperator, currentLang, contextualParameters); 389 } 390 391 return Optional.ofNullable(joinedQuery).orElse(new MatchNoneQuery()); 392 } 393 394 private Query _queryFromTransformedValAndRealOperator( 395 SearchCriterionDefinition criterionDefinition, 396 FoWrappedValue transformedVal, 397 Operator realOperator, 398 Collection<Returnable> returnables, 399 Collection<Searchable> searchables, 400 AdditionalParameterValueMap additionalParameters, 401 String currentLang, 402 Map<String, Object> contextualParameters) 403 { 404 Query queryOnCriterion = _queryOnCriterion(criterionDefinition, transformedVal, realOperator, currentLang, contextualParameters); 405 Optional<Searchable> searchable = criterionDefinition.getSearchable(); 406 Query joinedQuery; 407 408 if (searchable.isPresent()) 409 { 410 joinedQuery = searchable.get().joinQuery(queryOnCriterion, criterionDefinition, returnables, additionalParameters).orElse(null); 411 } 412 else 413 { 414 // common => all searchables are concerned 415 List<Query> joinedQueries = searchables.stream() 416 .map(s -> s.joinQuery(queryOnCriterion, criterionDefinition, returnables, additionalParameters)) 417 .flatMap(Optional::stream) 418 .toList(); 419 joinedQuery = joinedQueries.isEmpty() ? null : new OrQuery(joinedQueries); 420 } 421 return joinedQuery; 422 } 423 424 private Query _queryOnCriterion( 425 SearchCriterionDefinition criterionDefinition, 426 FoWrappedValue transformedVal, 427 Operator realOperator, 428 String currentLang, 429 Map<String, Object> contextualParameters) 430 { 431 Object unwrappedValue = transformedVal.getValue(); 432 if (transformedVal.requestEmptyValue()) 433 { 434 return (unwrappedValue instanceof Collection) 435 ? new OrQuery(criterionDefinition.getEmptyValueQuery(currentLang, contextualParameters), criterionDefinition.getQuery(unwrappedValue, realOperator, currentLang, contextualParameters)) 436 : criterionDefinition.getEmptyValueQuery(currentLang, contextualParameters); 437 } 438 439 return criterionDefinition.getQuery(unwrappedValue, realOperator, currentLang, contextualParameters); 440 } 441 442 private void _logTree(Logger logger, AbstractTreeNode<FOSearchCriterion> tree, Map<String, Object> userCriteria) 443 { 444 if (logger.isDebugEnabled()) 445 { 446 Function<FOSearchCriterion, String> leafStringifier = c -> 447 { 448 String id = c.getId(); 449 String value = String.valueOf(userCriteria.get(id)); 450 return "{" + id + ": Operator=" + c.getOperator() + ", Mode=" + c.getMode() + ", Value=" + value + ", StaticValue=" + c.getStaticValue() + "}"; 451 }; 452 logger.debug("\n" + TreePrinter.print(tree, leafStringifier)); 453 } 454 } 455 456 /** 457 * Get the filter query from arguments 458 * @param args the search arguments 459 * @return the filter query 460 */ 461 public Query getFilterQuery(SearchComponentArguments args) 462 { 463 SitemapElement currentPage = args.currentPage(); 464 Site currentSite = args.currentSite(); 465 String currentLang = args.currentLang(); 466 SearchServiceInstance serviceInstance = args.serviceInstance(); 467 468 return getFilterQuery(serviceInstance, currentSite, currentPage, currentLang); 469 } 470 471 /** 472 * Get the filter query 473 * @param serviceInstance the service instance 474 * @param site the site 475 * @param page the page 476 * @param lang the lang 477 * @return the filter query 478 */ 479 public Query getFilterQuery(SearchServiceInstance serviceInstance, Site site, SitemapElement page, String lang) 480 { 481 AdditionalParameterValueMap additionalParameterValues = serviceInstance.getAdditionalParameterValues(); 482 483 List<ContextQueriesWrapper> contextQueriesWrappers = serviceInstance.getContexts() 484 .stream() 485 .map(searchCtx -> _createContextQueriesWrapper(searchCtx, site, page, lang)) 486 .collect(Collectors.toList()); 487 488 return serviceInstance.getReturnables() 489 .stream() 490 .map(returnable -> returnable.filterReturnedDocumentQuery(contextQueriesWrappers, additionalParameterValues)) 491 .collect(OrQuery.collector()); 492 } 493 494 /** 495 * Creates a {@link ContextQueriesWrapper} given one {@link SearchContext} and the current site, page and lang. 496 * @param searchContext The search context 497 * @param currentSite The current site 498 * @param currentPage The current page 499 * @param currentLang The current lang 500 * @return The created wrapper of queries of a {@link SearchContext} 501 */ 502 protected ContextQueriesWrapper _createContextQueriesWrapper(SearchContext searchContext, Site currentSite, SitemapElement currentPage, String currentLang) 503 { 504 return new ContextQueriesWrapper( 505 searchContext.getSiteQuery(currentSite), 506 searchContext.getSitemapQuery(currentPage), 507 searchContext.getContextLang(currentLang), 508 searchContext.getTagQuery() 509 ); 510 } 511 512 /** 513 * Retrieves a {@link Map} of contextual parameters 514 * @param args the search component arguments 515 * @return the {@link Map} of contextual parameters 516 */ 517 public static Map<String, Object> getSearchComponentContextualParameters(SearchComponentArguments args) 518 { 519 Map<String, Object> contextualParameters = new HashMap<>(); 520 521 contextualParameters.put("searchContexts", args.serviceInstance().getContexts()); 522 contextualParameters.put("siteName", args.currentSite().getName()); 523 contextualParameters.put("lang", args.currentLang()); 524 525 return contextualParameters; 526 } 527}