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}