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