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}