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