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