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.SearchContext;
054import org.ametys.web.frontoffice.search.instance.model.SearchServiceCriterion;
055import org.ametys.web.frontoffice.search.instance.model.SearchServiceCriterionMode;
056import org.ametys.web.frontoffice.search.instance.model.SearchServiceCriterionMode.CriterionWrappedValue;
057import org.ametys.web.frontoffice.search.metamodel.AdditionalParameterValueMap;
058import org.ametys.web.frontoffice.search.metamodel.RestrictedEnumerator.RestrictedValues;
059import org.ametys.web.frontoffice.search.metamodel.Returnable;
060import org.ametys.web.frontoffice.search.metamodel.SearchServiceCriterionDefinition;
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<SearchServiceCriterion<?>> criterionTree = serviceInstance.getCriterionTree().get();
122        SearchUserInputs userInputs = args.userInputs();
123        
124        Map<String, Object> contextualParameters = getSearchComponentContextualParameters(args);
125        
126        Map<String, Object> userCriteria = checkValidInputs
127                ? checkValidInputs(criterionTree, userInputs.criteria(), userInputs instanceof UserPrefsSearchUserInputs, contextualParameters, args.logger())
128                : userInputs.criteria();
129        
130        if (logTree)
131        {
132            _logTree(args.logger(), criterionTree, userCriteria);
133        }
134        
135        return getCriterionTreeQuery(serviceInstance, userCriteria, args.currentSite(), args.currentLang());
136    }
137    
138    /**
139     * Get the criterion tree query
140     * @param serviceInstance the service instance
141     * @param userCriteria the user criteria
142     * @param site the site
143     * @param lang the lang
144     * @return the criterion tree query
145     */
146    public Query getCriterionTreeQuery(SearchServiceInstance serviceInstance, Map<String, Object> userCriteria, Site site, String lang)
147    {
148        AbstractTreeNode<SearchServiceCriterion<?>> criterionTree = serviceInstance.getCriterionTree().get();
149
150        Collection<Returnable> returnables = serviceInstance.getReturnables();
151        Collection<Searchable> searchables = serviceInstance.getSearchables();
152        AdditionalParameterValueMap additionalParameters = serviceInstance.getAdditionalParameterValues();
153        Map<String, Object> contextualParameters = _contextualParameters(site);
154        
155        List<Query> buildQueries = searchables.stream()
156                .map(s -> s.buildQuery(criterionTree, userCriteria, returnables, searchables, additionalParameters, lang, contextualParameters))
157                .collect(Collectors.toList());
158        
159        return buildQueries.isEmpty()
160                ? null
161                : buildQueries.size() == 1 ? buildQueries.get(0) : new OrQuery(buildQueries);
162    }
163    
164    /**
165     * Checks the user inputs are valid.
166     * @param <T> Type of the criterion value
167     * @param criterionTree The criterion tree of the service instance
168     * @param userCriteria The user input criteria
169     * @param isFromUserPref <code>true</code> if the criteria are from user pref. Then return only the valid input, otherwise throw an exception
170     * @param contextualParameters the contextual parameters
171     * @param logger The logger
172     * @return the filtered user criteria
173     * @throws InvalidUserInputException if at least one user input is invalid
174     */
175    protected <T> Map<String, Object> checkValidInputs(AbstractTreeNode<SearchServiceCriterion<?>> criterionTree, Map<String, Object> userCriteria, boolean isFromUserPref, Map<String, Object> contextualParameters, Logger logger) throws InvalidUserInputException
176    {
177        Map<String, Object> filteredUserCriteria = userCriteria;
178        
179        List<SearchServiceCriterion> criteria = criterionTree.getFlatLeaves()
180                .stream()
181                .map(TreeLeaf::getValue)
182                .collect(Collectors.toList());
183        
184        // Check user inputs are all declared by the service instance
185        Set<String> criterionIds = criteria.stream()
186                .filter(crit -> !crit.getMode().isStatic())
187                .map(SearchServiceCriterion::getName)
188                .collect(Collectors.toSet());
189        
190        // If the user input is from user pref, just ignore the invalid input
191        if (isFromUserPref)
192        {
193            filteredUserCriteria = filteredUserCriteria.entrySet()
194                                .stream()
195                                .filter(c -> this._isFromCriteria(criterionIds, c.getKey(), logger))
196                                .collect(Collectors.toMap(e -> e.getKey(), e -> e.getValue()));
197        }
198        else if (!CollectionUtils.containsAll(criterionIds, filteredUserCriteria.keySet()))
199        {
200            throw new InvalidUserInputException("At least one of the user input criterion is invalid because it was not declared by the service instance.");
201        }
202        
203        // Check values are among restricted ones (if so) and validate
204        for (SearchServiceCriterion criterion : criteria)
205        {
206            String criterionId = criterion.getName();
207            
208            if (filteredUserCriteria.containsKey(criterionId))
209            {
210                SearchServiceCriterionDefinition<T> criterionDefinition = criterion.getCriterionDefinition();
211                SearchServiceCriterionMode mode = criterion.getMode();
212                Optional<RestrictedValues<T>> restrictedValues = criterion.getRestrictedValues();
213                Validator validator = criterionDefinition.getValidator();
214                Object userCriterionValues = filteredUserCriteria.get(criterionId);
215                try
216                {
217                    checkValidInputValues(userCriterionValues, criterionId, criterionDefinition, mode, restrictedValues, validator, contextualParameters);
218                }
219                catch (InvalidUserInputException e)
220                {
221                    // If the user input is from user pref, just ignore the invalid input
222                    if (isFromUserPref)
223                    {
224                        filteredUserCriteria.remove(criterionId);
225                        if (logger.isDebugEnabled())
226                        {
227                            logger.debug("The user input criterion ({}) from user preferences is invalid because of the following error: {}", criterionId, e);
228                        }
229                    }
230                    else
231                    {
232                        throw e;
233                    }
234                }
235            }
236        }
237        
238        return filteredUserCriteria;
239    }
240    
241    private boolean _isFromCriteria(Set<String> criterionIds, String criterionId, Logger logger)
242    {
243        boolean isFromCriteria = criterionIds.contains(criterionId);
244        if (!isFromCriteria)
245        {
246            if (logger.isDebugEnabled())
247            {
248                logger.debug("The user input criterion ({}) from user preferences is invalid because it was not declared by the service instance.", criterionId);
249            }
250        }
251        return isFromCriteria;
252    }
253    
254    /**
255     * Checks the user inputs for one criterion (can be multiple) are valid
256     * @param <T> Type of the criterion value
257     * @param userCriterionValues The multiple user values (then it is a List) or the single user value
258     * @param criterionId The criterion id
259     * @param criterionDefinition the criterion definition
260     * @param mode The criterion mode
261     * @param optionalRestrictedValues The {@link RestrictedValues} if mode is {@link SearchServiceCriterionMode#RESTRICTED_USER_INPUT}
262     * @param validator The criterion validator
263     * @param contextualParameters The contextual parameters
264     * @throws InvalidUserInputException if at least one user input is invalid
265     */
266    protected <T> void checkValidInputValues(Object userCriterionValues, String criterionId, SearchServiceCriterionDefinition<T> criterionDefinition, SearchServiceCriterionMode mode, Optional<RestrictedValues<T>> optionalRestrictedValues, Validator validator, Map<String, Object> contextualParameters) throws InvalidUserInputException
267    {
268        if (userCriterionValues instanceof List<?>)
269        {
270            List<?> userCriterionMultipleValues = (List<?>) userCriterionValues;
271            for (Object userCriterionSingleValue : userCriterionMultipleValues)
272            {
273                checkValidInputSingleValue(userCriterionSingleValue, criterionId, criterionDefinition, mode, optionalRestrictedValues, validator, contextualParameters);
274            }
275        }
276        else
277        {
278            checkValidInputSingleValue(userCriterionValues, criterionId, criterionDefinition, mode, optionalRestrictedValues, validator, contextualParameters);
279        }
280    }
281    
282    /**
283     * Checks a single value of one user input for one criterion is valid
284     * @param <T> Type of the criterion value
285     * @param userCriterionSingleValue The single user value
286     * @param criterionId The criterion id
287     * @param criterionDefinition the criterion definition
288     * @param mode The criterion mode
289     * @param optionalRestrictedValues The {@link RestrictedValues} if mode is {@link SearchServiceCriterionMode#RESTRICTED_USER_INPUT}
290     * @param validator The criterion validator
291     * @param contextualParameters The contextual parameters
292     * @throws InvalidUserInputException if the user input is invalid
293     */
294    protected <T> void checkValidInputSingleValue(Object userCriterionSingleValue, String criterionId, SearchServiceCriterionDefinition<T> criterionDefinition, SearchServiceCriterionMode mode, Optional<RestrictedValues<T>> optionalRestrictedValues, Validator validator, Map<String, Object> contextualParameters) throws InvalidUserInputException
295    {
296        if (mode == SearchServiceCriterionMode.RESTRICTED_USER_INPUT)
297        {
298            try
299            {
300                Set<T> restrictedValues = optionalRestrictedValues.get().values().keySet();
301                
302                T userTypedValue = criterionDefinition.convertRestrictedValue(userCriterionSingleValue, contextualParameters);
303                
304                if (!restrictedValues.contains(userTypedValue)
305                    && (!SearchServiceCriterionMode.NONE_VALUE.equals(userCriterionSingleValue) || _isMandatory(validator)))
306                {
307                    throw new InvalidUserInputException("The user input criterion '" + userCriterionSingleValue + "' for criterion '" + criterionId + "' is invalid because it is not among declared restricted values (" + restrictedValues + ").");
308                }
309            }
310            catch (Exception e)
311            {
312                // An error occurred while retrieving restricted values
313                throw new IllegalStateException("An unexpected error occured. Unable to compute restricted values for criterion '" + criterionId + "'", e);
314            }
315        }
316        
317        List<I18nizableText> errors = Optional.ofNullable(validator)
318                                              .map(val -> val.validate(userCriterionSingleValue))
319                                              .filter(ValidationResult::hasErrors)
320                                              .map(ValidationResult::getErrors)
321                                              .orElseGet(ArrayList::new);
322        if (!errors.isEmpty())
323        {
324            String translatedErrors = errors.stream()
325                .map(_i18nUtils::translate)
326                .collect(Collectors.joining("\n"));
327            throw new InvalidUserInputException("The user input '" + userCriterionSingleValue + "' for criterion '" + criterionId + "' is invalid because of the following errors:\n" + translatedErrors);
328        }
329    }
330    
331    private boolean _isMandatory(Validator validator)
332    {
333        return validator != null
334                ? validator.getConfiguration().containsKey("mandatory") && (Boolean) validator.getConfiguration().get("mandatory")
335                : false;
336    }
337    
338    private Map<String, Object> _contextualParameters(Site currentSite)
339    {
340        return new HashMap<>(ImmutableMap.of("siteName", currentSite.getName()));
341    }
342    
343    /**
344     * Builds the query of the criterion tree
345     * @param criterionTree The criterion tree of the service instance
346     * @param userCriteria The user input criteria
347     * @param returnables The returnables of the service instance
348     * @param searchables The searchables of the service instance
349     * @param additionalParameters The values of additional parameters of the service instance
350     * @param currentLang The current lang
351     * @param filterCriterionPredicate A function to filter criterion. Can be null for TruePredicate
352     * @param contextualParameters the search contextual parameters.
353     * @return The query of the criterion tree
354     */
355    public Query buildQuery(
356            AbstractTreeNode<SearchServiceCriterion<?>> criterionTree,
357            Map<String, Object> userCriteria,
358            Collection<Returnable> returnables,
359            Collection<Searchable> searchables,
360            AdditionalParameterValueMap additionalParameters,
361            String currentLang,
362            Predicate<SearchServiceCriterion> filterCriterionPredicate,
363            Map<String, Object> contextualParameters)
364    {
365        Predicate<SearchServiceCriterion> predicate = filterCriterionPredicate != null ? filterCriterionPredicate : crit -> true;
366        Function<SearchServiceCriterion<?>, Query> queryMapper = crit -> singleCriterionToQuery(crit, userCriteria, returnables, searchables, additionalParameters, currentLang, predicate, contextualParameters);
367        return _advancedQueryBuilder.build(criterionTree, queryMapper);
368    }
369    
370    /**
371     * Builds the query of the single criterion
372     * @param criterion The criterion
373     * @param userCriteria The user input criteria
374     * @param returnables The returnables of the service instance
375     * @param searchables The searchables of the service instance
376     * @param additionalParameters The values of additional parameters of the service instance
377     * @param currentLang The current lang
378     * @param filterCriterionPredicate A function to filter criterion
379     * @param contextualParameters the search contextual parameters.
380     * @param <T> Type of the criterion value
381     * @return The query of the single criterion
382     */
383    public <T> Query singleCriterionToQuery(
384            SearchServiceCriterion<T> criterion,
385            Map<String, Object> userCriteria,
386            Collection<Returnable> returnables,
387            Collection<Searchable> searchables,
388            AdditionalParameterValueMap additionalParameters,
389            String currentLang,
390            Predicate<SearchServiceCriterion> filterCriterionPredicate,
391            Map<String, Object> contextualParameters)
392    {
393        SearchServiceCriterionMode mode = criterion.getMode();
394        CriterionWrappedValue val = mode.getValue(criterion, userCriteria, contextualParameters);
395        
396        // The criterion was not filled by the visitor or it is filtered
397        // Put an empty query. It will be ignored by And/OrQuery
398        Query joinedQuery;
399        if (val.getValue() == null || !filterCriterionPredicate.test(criterion))
400        {
401            joinedQuery = EMPTY_QUERY;
402        }
403        else
404        {
405            BiFunction<CriterionWrappedValue, Operator, Query> queryFunctionFromTransformedValAndRealOperator = (transformedVal, realOperator) -> _queryFromTransformedValAndRealOperator(
406                    criterion.getCriterionDefinition(),
407                    transformedVal,
408                    realOperator,
409                    returnables,
410                    searchables,
411                    additionalParameters,
412                    currentLang,
413                    contextualParameters);
414            
415            joinedQuery = _treeMaker.toQuery(val, criterion.getOperator(), queryFunctionFromTransformedValAndRealOperator, currentLang, contextualParameters);
416        }
417        
418        return Optional.ofNullable(joinedQuery).orElse(new MatchNoneQuery());
419    }
420    
421    private <T> Query _queryFromTransformedValAndRealOperator(
422            SearchServiceCriterionDefinition<T> criterionDefinition,
423            CriterionWrappedValue transformedVal,
424            Operator realOperator,
425            Collection<Returnable> returnables,
426            Collection<Searchable> searchables,
427            AdditionalParameterValueMap additionalParameters,
428            String currentLang,
429            Map<String, Object> contextualParameters)
430    {
431        Query queryOnCriterion = _queryOnCriterion(criterionDefinition, transformedVal, realOperator, currentLang, contextualParameters);
432        Optional<Searchable> searchable = criterionDefinition.getSearchable();
433        Query joinedQuery;
434        
435        if (searchable.isPresent())
436        {
437            joinedQuery = searchable.get().joinQuery(queryOnCriterion, criterionDefinition, returnables, additionalParameters).orElse(null);
438        }
439        else
440        {
441            // common => all searchables are concerned
442            List<Query> joinedQueries = searchables.stream()
443                    .map(s -> s.joinQuery(queryOnCriterion, criterionDefinition, returnables, additionalParameters))
444                    .flatMap(Optional::stream)
445                    .toList();
446            joinedQuery = joinedQueries.isEmpty() ? null : new OrQuery(joinedQueries);
447        }
448        return joinedQuery;
449    }
450    
451    private <T> Query _queryOnCriterion(
452            SearchServiceCriterionDefinition<T> criterionDefinition,
453            CriterionWrappedValue transformedVal,
454            Operator realOperator,
455            String currentLang,
456            Map<String, Object> contextualParameters)
457    {
458        Object unwrappedValue = transformedVal.getValue();
459        if (transformedVal.requestEmptyValue())
460        {
461            return transformedVal.hasValue()
462                    ? new OrQuery(criterionDefinition.getEmptyValueQuery(currentLang, contextualParameters), criterionDefinition.getQuery(unwrappedValue, realOperator, currentLang, contextualParameters))
463                    : criterionDefinition.getEmptyValueQuery(currentLang, contextualParameters);
464        }
465        else
466        {
467            return criterionDefinition.getQuery(unwrappedValue, realOperator, currentLang, contextualParameters);
468        }
469    }
470    
471    private void _logTree(Logger logger, AbstractTreeNode<SearchServiceCriterion<?>> tree, Map<String, Object> userCriteria)
472    {
473        if (logger.isDebugEnabled())
474        {
475            Function<SearchServiceCriterion<?>, String> leafStringifier = c ->
476            {
477                String id = c.getName();
478                String value = String.valueOf(userCriteria.get(id));
479                return "{" + id + ": Operator=" + c.getOperator() + ", Mode=" + c.getMode() + ", Value=" + value + ", StaticValue=" + c.getStaticValue() + "}";
480            };
481            logger.debug("\n" + TreePrinter.print(tree, leafStringifier));
482        }
483    }
484    
485    /**
486     * Get the filter query from arguments
487     * @param args the search arguments
488     * @return the filter query
489     */
490    public Query getFilterQuery(SearchComponentArguments args)
491    {
492        SitemapElement currentPage = args.currentPage();
493        Site currentSite = args.currentSite();
494        String currentLang = args.currentLang();
495        SearchServiceInstance serviceInstance = args.serviceInstance();
496        
497        return getFilterQuery(serviceInstance, currentSite, currentPage, currentLang);
498    }
499    
500    /**
501     * Get the filter query
502     * @param serviceInstance the service instance
503     * @param site the site
504     * @param page the page
505     * @param lang the lang
506     * @return the filter query
507     */
508    public Query getFilterQuery(SearchServiceInstance serviceInstance, Site site, SitemapElement page, String lang)
509    {
510        AdditionalParameterValueMap additionalParameterValues = serviceInstance.getAdditionalParameterValues();
511        
512        List<ContextQueriesWrapper> contextQueriesWrappers = serviceInstance.getContexts()
513            .stream()
514            .map(searchCtx -> _createContextQueriesWrapper(searchCtx, site, page, lang))
515            .collect(Collectors.toList());
516        
517        return serviceInstance.getReturnables()
518                .stream()
519                .map(returnable -> returnable.filterReturnedDocumentQuery(contextQueriesWrappers, additionalParameterValues))
520                .collect(OrQuery.collector());
521    }
522    
523    /**
524     * Creates a {@link ContextQueriesWrapper} given one {@link SearchContext} and the current site, page and lang.
525     * @param searchContext The search context
526     * @param currentSite The current site
527     * @param currentPage The current page
528     * @param currentLang The current lang
529     * @return The created wrapper of queries of a {@link SearchContext}
530     */
531    protected ContextQueriesWrapper _createContextQueriesWrapper(SearchContext searchContext, Site currentSite, SitemapElement currentPage, String currentLang)
532    {
533        return new ContextQueriesWrapper(
534                searchContext.getSiteQuery(currentSite),
535                searchContext.getSitemapQuery(currentPage),
536                searchContext.getContextLang(currentLang),
537                searchContext.getTagQuery()
538        );
539    }
540    
541    /**
542     * Retrieves a {@link Map} of contextual parameters
543     * @param args the search component arguments
544     * @return the {@link Map} of contextual parameters
545     */
546    public static Map<String, Object> getSearchComponentContextualParameters(SearchComponentArguments args)
547    {
548        Map<String, Object> contextualParameters = new HashMap<>();
549        
550        contextualParameters.put("searchContexts", args.serviceInstance().getContexts());
551        contextualParameters.put("siteName", args.currentSite().getName());
552        contextualParameters.put("lang", args.currentLang());
553        
554        return contextualParameters;
555    }
556}