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}