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