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.function.Predicate;
027import java.util.stream.Collectors;
028
029import org.apache.avalon.framework.component.Component;
030import org.apache.avalon.framework.service.ServiceException;
031import org.apache.avalon.framework.service.ServiceManager;
032import org.apache.avalon.framework.service.Serviceable;
033import org.apache.commons.collections4.CollectionUtils;
034import org.apache.commons.lang3.StringUtils;
035import org.slf4j.Logger;
036
037import org.ametys.cms.search.advanced.AbstractTreeNode;
038import org.ametys.cms.search.advanced.AdvancedQueryBuilder;
039import org.ametys.cms.search.advanced.TreeLeaf;
040import org.ametys.cms.search.advanced.TreeMaker;
041import org.ametys.cms.search.advanced.utils.TreePrinter;
042import org.ametys.cms.search.query.MatchNoneQuery;
043import org.ametys.cms.search.query.OrQuery;
044import org.ametys.cms.search.query.Query;
045import org.ametys.cms.search.query.Query.Operator;
046import org.ametys.cms.search.query.QuerySyntaxException;
047import org.ametys.core.util.I18nUtils;
048import org.ametys.runtime.i18n.I18nizableText;
049import org.ametys.runtime.parameter.Errors;
050import org.ametys.runtime.parameter.Validator;
051import org.ametys.web.frontoffice.search.instance.SearchServiceInstance;
052import org.ametys.web.frontoffice.search.instance.model.FOSearchCriterion;
053import org.ametys.web.frontoffice.search.instance.model.FOSearchCriterionMode;
054import org.ametys.web.frontoffice.search.instance.model.FOSearchCriterionMode.FoWrappedValue;
055import org.ametys.web.frontoffice.search.instance.model.SearchContext;
056import org.ametys.web.frontoffice.search.metamodel.AdditionalParameterValueMap;
057import org.ametys.web.frontoffice.search.metamodel.EnumeratedValues.RestrictedValues;
058import org.ametys.web.frontoffice.search.metamodel.Returnable;
059import org.ametys.web.frontoffice.search.metamodel.SearchCriterionDefinition;
060import org.ametys.web.frontoffice.search.metamodel.Searchable;
061import org.ametys.web.frontoffice.search.metamodel.context.ContextQueriesWrapper;
062import org.ametys.web.frontoffice.search.requesttime.SearchComponentArguments;
063import org.ametys.web.frontoffice.search.requesttime.input.SearchUserInputs;
064import org.ametys.web.frontoffice.search.requesttime.input.impl.UserPrefsSearchUserInputs;
065import org.ametys.web.repository.page.SitemapElement;
066import org.ametys.web.repository.site.Site;
067
068import com.google.common.collect.ImmutableMap;
069
070/**
071 * A helper for all search component
072 */
073public class SearchComponentHelper implements Serviceable, Component
074{
075    /** The avalon role. */
076    public static final String ROLE = SearchComponentHelper.class.getName();
077    
078    /** The {@link Query} building to the empty string */
079    public static final Query EMPTY_QUERY = new Query()
080    {
081        @Override
082        public String build() throws QuerySyntaxException
083        {
084            return StringUtils.EMPTY;
085        }
086        @Override
087        public String toString(int indent)
088        {
089            return StringUtils.repeat(' ', indent) + "EmptyQuery()";
090        }
091    };
092    
093    /** The builder of advanced queries */
094    protected AdvancedQueryBuilder _advancedQueryBuilder;
095    
096    /** The Advanced tree maker */
097    protected TreeMaker _treeMaker;
098
099    /** The utils for i18nizable texts */
100    protected I18nUtils _i18nUtils;
101    
102    public void service(ServiceManager manager) throws ServiceException
103    {
104        _advancedQueryBuilder = (AdvancedQueryBuilder) manager.lookup(AdvancedQueryBuilder.ROLE);
105        _treeMaker = (TreeMaker) manager.lookup(TreeMaker.ROLE);
106        _i18nUtils = (I18nUtils) manager.lookup(I18nUtils.ROLE);
107    }
108    
109    /**
110     * Get the criterion tree query from arguments
111     * @param args the search arguments
112     * @param checkValidInputs true if inputs needs to be checked
113     * @param logTree true if the criterion tree need to be logged
114     * @return the criterion tree query
115     * @throws InvalidUserInputException if an invalid user input occurred
116     */
117    public Query getCriterionTreeQuery(SearchComponentArguments args, boolean checkValidInputs, boolean logTree) throws InvalidUserInputException
118    {
119        SearchServiceInstance serviceInstance = args.serviceInstance();
120        AbstractTreeNode<FOSearchCriterion> criterionTree = serviceInstance.getCriterionTree().get();
121        SearchUserInputs userInputs = args.userInputs();
122        Map<String, Object> userCriteria = checkValidInputs 
123                ? checkValidInputs(criterionTree, userInputs.criteria(), userInputs instanceof UserPrefsSearchUserInputs, args.logger())
124                : userInputs.criteria();
125        
126        if (logTree)
127        {
128            _logTree(args.logger(), criterionTree, userCriteria);
129        }
130        
131        Collection<Returnable> returnables = serviceInstance.getReturnables();
132        Collection<Searchable> searchables = serviceInstance.getSearchables();
133        AdditionalParameterValueMap additionalParameters = serviceInstance.getAdditionalParameterValues();
134        Map<String, Object> contextualParameters = _contextualParameters(args.currentSite());
135        
136        
137        List<Query> buildQueries = searchables.stream()
138                .map(s -> s.buildQuery(criterionTree, userCriteria, returnables, searchables, additionalParameters, args.currentLang(), contextualParameters))
139                .collect(Collectors.toList());
140        
141        return buildQueries.isEmpty() 
142                ? null 
143                : buildQueries.size() == 1 ? buildQueries.get(0) : new OrQuery(buildQueries);
144    }
145    
146    /**
147     * Checks the user inputs are valid.
148     * @param criterionTree The criterion tree of the service instance
149     * @param userCriteria The user input criteria
150     * @param isFromUserPref <code>true</code> if the criteria are from user pref. Then return only the valid input, otherwise throw an exception
151     * @param logger The logger
152     * @return the filtered user criteria
153     * @throws InvalidUserInputException if at least one user input is invalid
154     */
155    protected Map<String, Object> checkValidInputs(AbstractTreeNode<FOSearchCriterion> criterionTree, Map<String, Object> userCriteria, boolean isFromUserPref, Logger logger) throws InvalidUserInputException
156    {
157        Map<String, Object> filteredUserCriteria = userCriteria;
158        
159        List<FOSearchCriterion> criteria = criterionTree.getFlatLeaves()
160                .stream()
161                .map(TreeLeaf::getValue)
162                .collect(Collectors.toList());
163        
164        // Check user inputs are all declared by the service instance
165        Set<String> criterionIds = criteria.stream()
166                .filter(crit -> !crit.getMode().isStatic())
167                .map(FOSearchCriterion::getId)
168                .collect(Collectors.toSet());
169        
170        // If the user input is from user pref, just ignore the invalid input
171        if (isFromUserPref)
172        {
173            filteredUserCriteria = filteredUserCriteria.entrySet()
174                                .stream()
175                                .filter(c -> this._isFromCriteria(criterionIds, c.getKey(), logger))
176                                .collect(Collectors.toMap(e -> e.getKey(), e -> e.getValue()));
177        }
178        else if (!CollectionUtils.containsAll(criterionIds, filteredUserCriteria.keySet()))
179        {
180            throw new InvalidUserInputException("At least one of the user input criterion is invalid because it was not declared by the service instance.");
181        }
182        
183        // Check values are among restricted ones (if so) and validate
184        for (FOSearchCriterion criterion : criteria)
185        {
186            String criterionId = criterion.getId();
187            
188            if (filteredUserCriteria.containsKey(criterionId))
189            {
190                FOSearchCriterionMode mode = criterion.getMode();
191                Optional<RestrictedValues> restrictedValues = criterion.getRestrictedValues();
192                boolean isMandatory = criterion.isMandatory();
193                Validator validator = criterion.getCriterionDefinition().getValidator();
194                Object userCriterionValues = filteredUserCriteria.get(criterionId);
195                try
196                {
197                    checkValidInputValues(userCriterionValues, criterionId, mode, restrictedValues, validator, isMandatory);
198                }
199                catch (InvalidUserInputException e) 
200                {
201                    // If the user input is from user pref, just ignore the invalid input
202                    if (isFromUserPref)
203                    {
204                        filteredUserCriteria.remove(criterionId);
205                        if (logger.isDebugEnabled())
206                        {
207                            logger.debug("The user input criterion ({}) from user preferences is invalid because of the following error: {}", criterionId, e);
208                        }
209                    }
210                    else
211                    {
212                        throw e;
213                    }
214                }
215            }
216        }
217        
218        return filteredUserCriteria;
219    }
220    
221    private boolean _isFromCriteria(Set<String> criterionIds, String criterionId, Logger logger)
222    {
223        boolean isFromCriteria = criterionIds.contains(criterionId);
224        if (!isFromCriteria)
225        {
226            if (logger.isDebugEnabled())
227            {
228                logger.debug("The user input criterion ({}) from user preferences is invalid because it was not declared by the service instance.", criterionId);
229            }
230        }
231        return isFromCriteria;
232    }
233    
234    /**
235     * Checks the user inputs for one criterion (can be multiple) are valid
236     * @param userCriterionValues The multiple user values (then it is a List) or the single user value
237     * @param criterionId The criterion id
238     * @param mode The criterion mode
239     * @param optionalRestrictedValues The {@link RestrictedValues} if mode is {@link FOSearchCriterionMode#RESTRICTED_USER_INPUT}
240     * @param validator The criterion validator
241     * @param isMandatory true if the criterion is linked to a mandatory data
242     * @throws InvalidUserInputException if at least one user input is invalid
243     */
244    protected void checkValidInputValues(Object userCriterionValues, String criterionId, FOSearchCriterionMode mode, Optional<RestrictedValues> optionalRestrictedValues, Validator validator, boolean isMandatory) throws InvalidUserInputException
245    {
246        if (userCriterionValues instanceof List<?>)
247        {
248            List<?> userCriterionMultipleValues = (List<?>) userCriterionValues;
249            for (Object userCriterionSingleValue : userCriterionMultipleValues)
250            {
251                checkValidInputSingleValue(userCriterionSingleValue, criterionId, mode, optionalRestrictedValues, validator, isMandatory);
252            }
253        }
254        else
255        {
256            checkValidInputSingleValue(userCriterionValues, criterionId, mode, optionalRestrictedValues, validator, isMandatory);
257        }
258    }
259    
260    /**
261     * Checks a single value of one user input for one criterion is valid
262     * @param userCriterionSingleValue The single user value
263     * @param criterionId The criterion id
264     * @param mode The criterion mode
265     * @param optionalRestrictedValues The {@link RestrictedValues} if mode is {@link FOSearchCriterionMode#RESTRICTED_USER_INPUT}
266     * @param validator The criterion validator
267     * @param isMandatory true if the criterion is linked to a mandatory data
268     * @throws InvalidUserInputException if the user input is invalid
269     */
270    protected void checkValidInputSingleValue(Object userCriterionSingleValue, String criterionId, FOSearchCriterionMode mode, Optional<RestrictedValues> optionalRestrictedValues, Validator validator, boolean isMandatory) throws InvalidUserInputException
271    {
272        if (mode == FOSearchCriterionMode.RESTRICTED_USER_INPUT)
273        {
274            Set<Object> restrictedValues = optionalRestrictedValues.get().values().keySet();
275            
276            if (!restrictedValues.contains(userCriterionSingleValue)
277                && (!FOSearchCriterionMode.NONE_VALUE.equals(userCriterionSingleValue) || isMandatory))
278            {
279                throw new InvalidUserInputException("The user input criterion '" + userCriterionSingleValue + "' for criterion '" + criterionId + "' is invalid because it is not among declared restricted values (" + restrictedValues + ").");
280            }
281        }
282        
283        Errors errorStructure = new Errors();
284        Optional.ofNullable(validator)
285                .ifPresent(val -> val.validate(userCriterionSingleValue, errorStructure));
286        List<I18nizableText> errors = errorStructure.getErrors();
287        if (!errors.isEmpty())
288        {
289            String translatedErrors = errors.stream()
290                .map(_i18nUtils::translate)
291                .collect(Collectors.joining("\n"));
292            throw new InvalidUserInputException("The user input '" + userCriterionSingleValue + "' for criterion '" + criterionId + "' is invalid because of the following errors:\n" + translatedErrors);
293        }
294    }
295    
296    private Map<String, Object> _contextualParameters(Site currentSite)
297    {
298        return new HashMap<>(ImmutableMap.of("siteName", currentSite.getName()));
299    }
300    
301    /**
302     * Builds the query of the criterion tree
303     * @param criterionTree The criterion tree of the service instance
304     * @param userCriteria The user input criteria
305     * @param returnables The returnables of the service instance
306     * @param searchables The searchables of the service instance
307     * @param additionalParameters The values of additional parameters of the service instance
308     * @param currentLang The current lang
309     * @param filterCriterionPredicate A function to filter criterion. Can be null for TruePredicate
310     * @param contextualParameters the search contextual parameters. 
311     * @return The query of the criterion tree
312     */
313    public Query buildQuery(
314            AbstractTreeNode<FOSearchCriterion> criterionTree, 
315            Map<String, Object> userCriteria, 
316            Collection<Returnable> returnables, 
317            Collection<Searchable> searchables, 
318            AdditionalParameterValueMap additionalParameters, 
319            String currentLang,
320            Predicate<FOSearchCriterion> filterCriterionPredicate,
321            Map<String, Object> contextualParameters)
322    {
323        Predicate<FOSearchCriterion> predicate = filterCriterionPredicate != null ? filterCriterionPredicate : crit -> true;
324        Function<FOSearchCriterion, Query> queryMapper = crit -> singleCriterionToQuery(crit, userCriteria, returnables, searchables, additionalParameters, currentLang, predicate, contextualParameters);
325        return _advancedQueryBuilder.build(criterionTree, queryMapper);
326    }
327    
328    /**
329     * Builds the query of the single criterion
330     * @param searchCriterion The criterion
331     * @param userCriteria The user input criteria
332     * @param returnables The returnables of the service instance
333     * @param searchables The searchables of the service instance
334     * @param additionalParameters The values of additional parameters of the service instance
335     * @param currentLang The current lang
336     * @param filterCriterionPredicate A function to filter criterion
337     * @param contextualParameters the search contextual parameters. 
338     * @return The query of the single criterion
339     */
340    public Query singleCriterionToQuery(
341            FOSearchCriterion searchCriterion, 
342            Map<String, Object> userCriteria, 
343            Collection<Returnable> returnables, 
344            Collection<Searchable> searchables, 
345            AdditionalParameterValueMap additionalParameters, 
346            String currentLang, 
347            Predicate<FOSearchCriterion> filterCriterionPredicate, 
348            Map<String, Object> contextualParameters)
349    {
350        FOSearchCriterionMode mode = searchCriterion.getMode();
351        FoWrappedValue val = mode.getValue(searchCriterion, userCriteria, contextualParameters);
352        
353        // The criterion was not filled by the visitor or it is filtered
354        // Put an empty query. It will be ignored by And/OrQuery
355        Query joinedQuery;
356        if (val.getValue() == null || !filterCriterionPredicate.test(searchCriterion))
357        {
358            joinedQuery = EMPTY_QUERY;
359        }
360        else
361        {
362            BiFunction<FoWrappedValue, Operator, Query> queryFunctionFromTransformedValAndRealOperator = (transformedVal, realOperator) -> _queryFromTransformedValAndRealOperator(
363                    searchCriterion.getCriterionDefinition(), 
364                    transformedVal, 
365                    realOperator, 
366                    returnables, 
367                    searchables, 
368                    additionalParameters, 
369                    currentLang, 
370                    contextualParameters);
371            
372            joinedQuery = _treeMaker.toQuery(val, searchCriterion.getOperator(), queryFunctionFromTransformedValAndRealOperator, currentLang, contextualParameters);
373        }
374        
375        return Optional.ofNullable(joinedQuery).orElse(new MatchNoneQuery());
376    }
377    
378    private Query _queryFromTransformedValAndRealOperator(
379            SearchCriterionDefinition criterionDefinition, 
380            FoWrappedValue transformedVal, 
381            Operator realOperator, 
382            Collection<Returnable> returnables, 
383            Collection<Searchable> searchables, 
384            AdditionalParameterValueMap additionalParameters, 
385            String currentLang, 
386            Map<String, Object> contextualParameters)
387    {
388        Query queryOnCriterion = _queryOnCriterion(criterionDefinition, transformedVal, realOperator, currentLang, contextualParameters);
389        Optional<Searchable> searchable = criterionDefinition.getSearchable();
390        Query joinedQuery;
391        
392        if (searchable.isPresent())
393        {
394            joinedQuery = searchable.get().joinQuery(queryOnCriterion, criterionDefinition, returnables, additionalParameters).orElse(null);
395        }
396        else
397        {
398            // common => all searchables are concerned
399            List<Query> joinedQueries = searchables.stream()
400                    .map(s -> s.joinQuery(queryOnCriterion, criterionDefinition, returnables, additionalParameters))
401                    .filter(Optional::isPresent)
402                    .map(Optional::get)
403                    .collect(Collectors.toList());
404            joinedQuery = joinedQueries.isEmpty() ? null : new OrQuery(joinedQueries);
405        }
406        return joinedQuery;
407    }
408    
409    private Query _queryOnCriterion(
410            SearchCriterionDefinition criterionDefinition, 
411            FoWrappedValue transformedVal, 
412            Operator realOperator, 
413            String currentLang, 
414            Map<String, Object> contextualParameters)
415    {
416        Object unwrappedValue = transformedVal.getValue();
417        if (transformedVal.requestEmptyValue())
418        {
419            return (unwrappedValue instanceof Collection) 
420                        ? new OrQuery(criterionDefinition.getEmptyValueQuery(currentLang, contextualParameters), criterionDefinition.getQuery(unwrappedValue, realOperator, currentLang, contextualParameters))
421                        : criterionDefinition.getEmptyValueQuery(currentLang, contextualParameters);
422        }
423        
424        return criterionDefinition.getQuery(unwrappedValue, realOperator, currentLang, contextualParameters);
425    }
426    
427    private void _logTree(Logger logger, AbstractTreeNode<FOSearchCriterion> tree, Map<String, Object> userCriteria)
428    {
429        if (logger.isDebugEnabled())
430        {
431            Function<FOSearchCriterion, String> leafStringifier = c ->
432            {
433                String id = c.getId();
434                String value = String.valueOf(userCriteria.get(id));
435                return "{" + id + ": Operator=" + c.getOperator() + ", Mode=" + c.getMode() + ", Value=" + value + ", StaticValue=" + c.getStaticValue() + "}";
436            };
437            logger.debug("\n" + TreePrinter.print(tree, leafStringifier));
438        }
439    }
440    
441    /**
442     * Get the filter query from arguments
443     * @param args the search arguments
444     * @return the filter query
445     */
446    public Query getFilterQuery(SearchComponentArguments args)
447    {
448        SitemapElement currentPage = args.currentPage();
449        Site currentSite = args.currentSite();
450        String currentLang = args.currentLang();
451        SearchServiceInstance serviceInstance = args.serviceInstance();
452        AdditionalParameterValueMap additionalParameterValues = serviceInstance.getAdditionalParameterValues();
453        
454        List<ContextQueriesWrapper> contextQueriesWrappers = serviceInstance.getContexts()
455            .stream()
456            .map(searchCtx -> _createContextQueriesWrapper(searchCtx, currentSite, currentPage, currentLang))
457            .collect(Collectors.toList());
458        
459        return args.serviceInstance().getReturnables()
460                .stream()
461                .map(returnable -> returnable.filterReturnedDocumentQuery(contextQueriesWrappers, additionalParameterValues))
462                .collect(OrQuery.collector());
463    }
464    
465    /**
466     * Creates a {@link ContextQueriesWrapper} given one {@link SearchContext} and the current site, page and lang.
467     * @param searchContext The search context
468     * @param currentSite The current site
469     * @param currentPage The current page
470     * @param currentLang The current lang
471     * @return The created wrapper of queries of a {@link SearchContext}
472     */
473    protected ContextQueriesWrapper _createContextQueriesWrapper(SearchContext searchContext, Site currentSite, SitemapElement currentPage, String currentLang)
474    {
475        return new ContextQueriesWrapper(
476                searchContext.getSiteQuery(currentSite),
477                searchContext.getSitemapQuery(currentPage),
478                searchContext.getContextLang(currentLang),
479                searchContext.getTagQuery()
480        );
481    }
482    
483    /**
484     * Retrieves a {@link Map} of contextual parameters
485     * @param args the search component arguments
486     * @return the {@link Map} of contextual parameters
487     */
488    public static Map<String, Object> getSearchComponentContextualParameters(SearchComponentArguments args)
489    {
490        Map<String, Object> contextualParameters = new HashMap<>();
491        
492        contextualParameters.put("searchContexts", args.serviceInstance().getContexts());
493        contextualParameters.put("siteName", args.currentSite().getName());
494        contextualParameters.put("lang", args.currentLang());
495        
496        return contextualParameters;
497    }
498}