001/* 002 * Copyright 2018 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.List; 020import java.util.Map; 021import java.util.Optional; 022import java.util.Set; 023import java.util.function.BiFunction; 024import java.util.function.Function; 025import java.util.stream.Collectors; 026 027import org.apache.avalon.framework.service.ServiceException; 028import org.apache.avalon.framework.service.ServiceManager; 029import org.apache.avalon.framework.service.Serviceable; 030import org.apache.commons.collections4.CollectionUtils; 031import org.apache.commons.lang3.StringUtils; 032import org.slf4j.Logger; 033 034import org.ametys.cms.search.advanced.AbstractTreeNode; 035import org.ametys.cms.search.advanced.AdvancedQueryBuilder; 036import org.ametys.cms.search.advanced.TreeLeaf; 037import org.ametys.cms.search.advanced.TreeMaker; 038import org.ametys.cms.search.advanced.utils.TreePrinter; 039import org.ametys.cms.search.query.MatchNoneQuery; 040import org.ametys.cms.search.query.OrQuery; 041import org.ametys.cms.search.query.Query; 042import org.ametys.cms.search.query.Query.Operator; 043import org.ametys.cms.search.query.QuerySyntaxException; 044import org.ametys.core.util.I18nUtils; 045import org.ametys.runtime.i18n.I18nizableText; 046import org.ametys.runtime.parameter.Errors; 047import org.ametys.runtime.parameter.Validator; 048import org.ametys.web.frontoffice.search.instance.SearchServiceInstance; 049import org.ametys.web.frontoffice.search.instance.model.FOSearchCriterion; 050import org.ametys.web.frontoffice.search.instance.model.FOSearchCriterionMode; 051import org.ametys.web.frontoffice.search.metamodel.AdditionalParameterValueMap; 052import org.ametys.web.frontoffice.search.metamodel.EnumeratedValues.RestrictedValues; 053import org.ametys.web.frontoffice.search.metamodel.Returnable; 054import org.ametys.web.frontoffice.search.metamodel.SearchCriterionDefinition; 055import org.ametys.web.frontoffice.search.metamodel.Searchable; 056import org.ametys.web.frontoffice.search.requesttime.SearchComponent; 057import org.ametys.web.frontoffice.search.requesttime.SearchComponentArguments; 058import org.ametys.web.repository.site.Site; 059 060import com.google.common.collect.ImmutableMap; 061 062/** 063 * {@link SearchComponent} for transforming a {@link AbstractTreeNode} of {@link FOSearchCriterion} into {@link Query queries} 064 */ 065public class CriterionTreeSearchComponent implements SearchComponent, Serviceable 066{ 067 /** The {@link Query} building to the empty string */ 068 protected static final Query __EMPTY_QUERY = new Query() 069 { 070 @Override 071 public String build() throws QuerySyntaxException 072 { 073 return StringUtils.EMPTY; 074 } 075 @Override 076 public String toString(int indent) 077 { 078 return StringUtils.repeat(' ', indent) + "EmptyQuery()"; 079 } 080 }; 081 082 /** The utils for i18nizable texts */ 083 protected I18nUtils _i18nUtils; 084 /** The builder of advanced queries */ 085 protected AdvancedQueryBuilder _advancedQueryBuilder; 086 /** The Advanced tree maker */ 087 protected TreeMaker _treeMaker; 088 089 @Override 090 public void service(ServiceManager manager) throws ServiceException 091 { 092 _i18nUtils = (I18nUtils) manager.lookup(I18nUtils.ROLE); 093 _advancedQueryBuilder = (AdvancedQueryBuilder) manager.lookup(AdvancedQueryBuilder.ROLE); 094 _treeMaker = (TreeMaker) manager.lookup(TreeMaker.ROLE); 095 } 096 097 @Override 098 public int priority() 099 { 100 return SEARCH_PRIORITY - 8000; 101 } 102 103 @Override 104 public boolean supports(SearchComponentArguments args) 105 { 106 return args.launchSearch() && args.serviceInstance().getCriterionTree().isPresent(); 107 } 108 109 @Override 110 public void execute(SearchComponentArguments args) throws Exception 111 { 112 SearchServiceInstance serviceInstance = args.serviceInstance(); 113 AbstractTreeNode<FOSearchCriterion> criterionTree = serviceInstance.getCriterionTree().get(); 114 Map<String, Object> userCriteria = args.userInputs().criteria(); 115 checkValidInputs(criterionTree, userCriteria); 116 117 _logTree(args.logger(), criterionTree, userCriteria); 118 119 Collection<Returnable> returnables = serviceInstance.getReturnables(); 120 Collection<Searchable> searchables = serviceInstance.getSearchables(); 121 AdditionalParameterValueMap additionalParameters = serviceInstance.getAdditionalParameterValues(); 122 Map<String, Object> contextualParameters = _contextualParameters(args.currentSite()); 123 Query criterionTreeQuery = buildQuery(criterionTree, userCriteria, returnables, searchables, additionalParameters, args.currentLang(), contextualParameters); 124 args.searcher().withQuery(criterionTreeQuery); 125 } 126 127 private Map<String, Object> _contextualParameters(Site currentSite) 128 { 129 return ImmutableMap.of("siteName", currentSite.getName()); 130 } 131 132 /** 133 * Checks the user inputs are valid 134 * @param criterionTree The criterion tree of the service instance 135 * @param userCriteria The user input criteria 136 * @throws InvalidUserInputException if at least one user input is invalid 137 */ 138 protected void checkValidInputs(AbstractTreeNode<FOSearchCriterion> criterionTree, Map<String, Object> userCriteria) throws InvalidUserInputException 139 { 140 List<FOSearchCriterion> criteria = criterionTree.getFlatLeaves() 141 .stream() 142 .map(TreeLeaf::getValue) 143 .collect(Collectors.toList()); 144 145 // Check user inputs are all declared by the service instance 146 Set<String> criterionIds = criteria.stream() 147 .filter(crit -> crit.getMode() != FOSearchCriterionMode.STATIC) 148 .map(FOSearchCriterion::getId) 149 .collect(Collectors.toSet()); 150 if (!CollectionUtils.containsAll(criterionIds, userCriteria.keySet())) 151 { 152 throw new InvalidUserInputException("At least one of the user input criteria is invalid because it was not declared by the service instance."); 153 } 154 155 // Check values are among restricted ones (if so) and validate 156 for (FOSearchCriterion criterion : criteria) 157 { 158 String criterionId = criterion.getId(); 159 160 if (userCriteria.containsKey(criterionId)) 161 { 162 FOSearchCriterionMode mode = criterion.getMode(); 163 Optional<RestrictedValues> restrictedValues = criterion.getRestrictedValues(); 164 Validator validator = criterion.getCriterionDefinition().getValidator(); 165 Object userCriterionValues = userCriteria.get(criterionId); 166 checkValidInputValues(userCriterionValues, criterionId, mode, restrictedValues, validator); 167 } 168 } 169 } 170 171 /** 172 * Checks the user inputs for one criterion (can be multiple) are valid 173 * @param userCriterionValues The multiple user values (then it is a List) or the single user value 174 * @param criterionId The criterion id 175 * @param mode The criterion mode 176 * @param optionalRestrictedValues The {@link RestrictedValues} if mode is {@link FOSearchCriterionMode#RESTRICTED_USER_INPUT} 177 * @param validator The criterion validator 178 * @throws InvalidUserInputException if at least one user input is invalid 179 */ 180 protected void checkValidInputValues(Object userCriterionValues, String criterionId, FOSearchCriterionMode mode, Optional<RestrictedValues> optionalRestrictedValues, Validator validator) throws InvalidUserInputException 181 { 182 if (userCriterionValues instanceof List<?>) 183 { 184 List<?> userCriterionMultipleValues = (List<?>) userCriterionValues; 185 for (Object userCriterionSingleValue : userCriterionMultipleValues) 186 { 187 checkValidInputSingleValue(userCriterionSingleValue, criterionId, mode, optionalRestrictedValues, validator); 188 } 189 } 190 else 191 { 192 checkValidInputSingleValue(userCriterionValues, criterionId, mode, optionalRestrictedValues, validator); 193 } 194 } 195 196 /** 197 * Checks a single value of one user input for one criterion is valid 198 * @param userCriterionSingleValue The single user value 199 * @param criterionId The criterion id 200 * @param mode The criterion mode 201 * @param optionalRestrictedValues The {@link RestrictedValues} if mode is {@link FOSearchCriterionMode#RESTRICTED_USER_INPUT} 202 * @param validator The criterion validator 203 * @throws InvalidUserInputException if the user input is invalid 204 */ 205 protected void checkValidInputSingleValue(Object userCriterionSingleValue, String criterionId, FOSearchCriterionMode mode, Optional<RestrictedValues> optionalRestrictedValues, Validator validator) throws InvalidUserInputException 206 { 207 if (mode == FOSearchCriterionMode.RESTRICTED_USER_INPUT) 208 { 209 Set<Object> restrictedValues = optionalRestrictedValues.get().values().keySet(); 210 if (!restrictedValues.contains(userCriterionSingleValue)) 211 { 212 throw new InvalidUserInputException("The user input criterion '" + userCriterionSingleValue + "' for criterion '" + criterionId + "' is invalid because it is not among declared restricted values (" + restrictedValues + ")."); 213 } 214 } 215 216 Errors errorStructure = new Errors(); 217 Optional.ofNullable(validator) 218 .ifPresent(val -> val.validate(userCriterionSingleValue, errorStructure)); 219 List<I18nizableText> errors = errorStructure.getErrors(); 220 if (!errors.isEmpty()) 221 { 222 String translatedErrors = errors.stream() 223 .map(_i18nUtils::translate) 224 .collect(Collectors.joining("\n")); 225 throw new InvalidUserInputException("The user input '" + userCriterionSingleValue + "' for criterion '" + criterionId + "' is invalid because of the following errors:\n" + translatedErrors); 226 } 227 } 228 229 private void _logTree(Logger logger, AbstractTreeNode<FOSearchCriterion> tree, Map<String, Object> userCriteria) 230 { 231 if (logger.isDebugEnabled()) 232 { 233 Function<FOSearchCriterion, String> leafStringifier = c -> 234 { 235 String id = c.getId(); 236 String value = String.valueOf(userCriteria.get(id)); 237 return "{" + id + ": Operator=" + c.getOperator() + ", Mode=" + c.getMode() + ", Value=" + value + ", StaticValue=" + c.getStaticValue() + "}"; 238 }; 239 logger.debug("\n" + TreePrinter.print(tree, leafStringifier)); 240 } 241 } 242 243 /** 244 * Builds the query of the criterion tree 245 * @param criterionTree The criterion tree of the service instance 246 * @param userCriteria The user input criteria 247 * @param returnables The returnables of the service instance 248 * @param searchables The searchables of the service instance 249 * @param additionalParameters The values of additional parameters of the service instance 250 * @param currentLang The current lang 251 * @param contextualParameters the search contextual parameters. 252 * @return The query of the criterion tree 253 */ 254 protected Query buildQuery( 255 AbstractTreeNode<FOSearchCriterion> criterionTree, 256 Map<String, Object> userCriteria, 257 Collection<Returnable> returnables, 258 Collection<Searchable> searchables, 259 AdditionalParameterValueMap additionalParameters, 260 String currentLang, 261 Map<String, Object> contextualParameters) 262 { 263 Function<FOSearchCriterion, Query> queryMapper = crit -> singleCriterionToQuery(crit, userCriteria, returnables, searchables, additionalParameters, currentLang, contextualParameters); 264 return _advancedQueryBuilder.build(criterionTree, queryMapper); 265 } 266 267 /** 268 * Builds the query of the single criterion 269 * @param searchCriterion The criterion 270 * @param userCriteria The user input criteria 271 * @param returnables The returnables of the service instance 272 * @param searchables The searchables of the service instance 273 * @param additionalParameters The values of additional parameters of the service instance 274 * @param currentLang The current lang 275 * @param contextualParameters the search contextual parameters. 276 * @return The query of the single criterion 277 */ 278 protected Query singleCriterionToQuery( 279 FOSearchCriterion searchCriterion, 280 Map<String, Object> userCriteria, 281 Collection<Returnable> returnables, 282 Collection<Searchable> searchables, 283 AdditionalParameterValueMap additionalParameters, 284 String currentLang, 285 Map<String, Object> contextualParameters) 286 { 287 FOSearchCriterionMode mode = searchCriterion.getMode(); 288 Object val = mode.getValue(searchCriterion, userCriteria, contextualParameters); 289 290 Query joinedQuery; 291 if (mode == FOSearchCriterionMode.USER_INPUT && val == null) 292 { 293 // The criterion was not filled by the visitor 294 // Put an empty query. It will be ignored by And/OrQuery 295 joinedQuery = __EMPTY_QUERY; 296 } 297 else 298 { 299 BiFunction<Object, Operator, Query> queryFunctionFromTransformedValAndRealOperator = (transformedVal, realOperator) -> _queryFromTransformedValAndRealOperator(searchCriterion.getCriterionDefinition(), transformedVal, realOperator, returnables, searchables, additionalParameters, currentLang, contextualParameters); 300 301 joinedQuery = _treeMaker.toQuery(val, searchCriterion.getOperator(), queryFunctionFromTransformedValAndRealOperator, currentLang, contextualParameters); 302 } 303 304 return Optional.ofNullable(joinedQuery).orElse(new MatchNoneQuery()); 305 } 306 307 private Query _queryFromTransformedValAndRealOperator( 308 SearchCriterionDefinition criterionDefinition, 309 Object transformedVal, 310 Operator realOperator, 311 Collection<Returnable> returnables, 312 Collection<Searchable> searchables, 313 AdditionalParameterValueMap additionalParameters, 314 String currentLang, 315 Map<String, Object> contextualParameters) 316 { 317 Query queryOnCriterion = criterionDefinition.getQuery(transformedVal, realOperator, currentLang, contextualParameters); 318 Optional<Searchable> searchable = criterionDefinition.getSearchable(); 319 Query joinedQuery; 320 321 if (searchable.isPresent()) 322 { 323 joinedQuery = searchable.get().joinQuery(queryOnCriterion, returnables, additionalParameters).orElse(null); 324 } 325 else 326 { 327 // common => all searchables are concerned 328 List<Query> joinedQueries = searchables.stream() 329 .map(s -> s.joinQuery(queryOnCriterion, returnables, additionalParameters)) 330 .filter(Optional::isPresent) 331 .map(Optional::get) 332 .collect(Collectors.toList()); 333 joinedQuery = joinedQueries.isEmpty() ? null : new OrQuery(joinedQueries); 334 } 335 return joinedQuery; 336 } 337}