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        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<SearchServiceCriterion<?>> 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<SearchServiceCriterion<?>> criterionTree, Map<String, Object> userCriteria, boolean isFromUserPref, Logger logger) throws InvalidUserInputException
171    {
172        Map<String, Object> filteredUserCriteria = userCriteria;
173        
174        List<SearchServiceCriterion> 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(SearchServiceCriterion::getName)
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 (SearchServiceCriterion criterion : criteria)
200        {
201            String criterionId = criterion.getName();
202            
203            if (filteredUserCriteria.containsKey(criterionId))
204            {
205                SearchServiceCriterionDefinition criterionDefinition = criterion.getCriterionDefinition();
206                SearchServiceCriterionMode mode = criterion.getMode();
207                Optional<RestrictedValues> restrictedValues = criterion.getRestrictedValues();
208                Validator validator = criterionDefinition.getValidator();
209                Object userCriterionValues = filteredUserCriteria.get(criterionId);
210                try
211                {
212                    checkValidInputValues(userCriterionValues, criterionId, criterionDefinition, mode, restrictedValues, validator);
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 criterionDefinition the criterion definition
254     * @param mode The criterion mode
255     * @param optionalRestrictedValues The {@link RestrictedValues} if mode is {@link SearchServiceCriterionMode#RESTRICTED_USER_INPUT}
256     * @param validator The criterion validator
257     * @throws InvalidUserInputException if at least one user input is invalid
258     */
259    protected void checkValidInputValues(Object userCriterionValues, String criterionId, SearchServiceCriterionDefinition criterionDefinition, SearchServiceCriterionMode mode, Optional<RestrictedValues> optionalRestrictedValues, Validator validator) throws InvalidUserInputException
260    {
261        if (userCriterionValues instanceof List<?>)
262        {
263            List<?> userCriterionMultipleValues = (List<?>) userCriterionValues;
264            for (Object userCriterionSingleValue : userCriterionMultipleValues)
265            {
266                checkValidInputSingleValue(convertUserCriterionValue(userCriterionSingleValue, criterionDefinition), criterionId, mode, optionalRestrictedValues, validator);
267            }
268        }
269        else
270        {
271            checkValidInputSingleValue(convertUserCriterionValue(userCriterionValues, criterionDefinition), criterionId, mode, optionalRestrictedValues, validator);
272        }
273    }
274    
275    /**
276     * Converts the given user criterion value
277     * @param userCriterionValue The single user value
278     * @param criterionDefinition the criterion definition
279     * @return the converted user value
280     */
281    protected Object convertUserCriterionValue(Object userCriterionValue, SearchServiceCriterionDefinition criterionDefinition)
282    {
283        return SearchServiceCriterionMode.NONE_VALUE.equals(userCriterionValue)
284                ? userCriterionValue
285                : criterionDefinition.convertRestrictedValue(userCriterionValue);
286    }
287    
288    
289    /**
290     * Checks a single value of one user input for one criterion is valid
291     * @param userCriterionSingleValue The single user value
292     * @param criterionId The criterion id
293     * @param mode The criterion mode
294     * @param optionalRestrictedValues The {@link RestrictedValues} if mode is {@link SearchServiceCriterionMode#RESTRICTED_USER_INPUT}
295     * @param validator The criterion validator
296     * @throws InvalidUserInputException if the user input is invalid
297     */
298    protected void checkValidInputSingleValue(Object userCriterionSingleValue, String criterionId, SearchServiceCriterionMode mode, Optional<RestrictedValues> optionalRestrictedValues, Validator validator) throws InvalidUserInputException
299    {
300        if (mode == SearchServiceCriterionMode.RESTRICTED_USER_INPUT)
301        {
302            try
303            {
304                Set<Object> restrictedValues = optionalRestrictedValues.get().values().keySet();
305                
306                if (!restrictedValues.contains(userCriterionSingleValue)
307                    && (!SearchServiceCriterionMode.NONE_VALUE.equals(userCriterionSingleValue) || _isMandatory(validator)))
308                {
309                    throw new InvalidUserInputException("The user input criterion '" + userCriterionSingleValue + "' for criterion '" + criterionId + "' is invalid because it is not among declared restricted values (" + restrictedValues + ").");
310                }
311            }
312            catch (Exception e)
313            {
314                // An error occurred while retrieving restricted values
315                throw new IllegalStateException("An unexpected error occured. Unable to compute restricted values for criterion '" + criterionId + "'", e);
316            }
317        }
318        
319        List<I18nizableText> errors = Optional.ofNullable(validator)
320                                              .map(val -> val.validate(userCriterionSingleValue))
321                                              .filter(ValidationResult::hasErrors)
322                                              .map(ValidationResult::getErrors)
323                                              .orElseGet(ArrayList::new);
324        if (!errors.isEmpty())
325        {
326            String translatedErrors = errors.stream()
327                .map(_i18nUtils::translate)
328                .collect(Collectors.joining("\n"));
329            throw new InvalidUserInputException("The user input '" + userCriterionSingleValue + "' for criterion '" + criterionId + "' is invalid because of the following errors:\n" + translatedErrors);
330        }
331    }
332    
333    private boolean _isMandatory(Validator validator)
334    {
335        return validator != null
336                ? validator.getConfiguration().containsKey("mandatory") && (Boolean) validator.getConfiguration().get("mandatory")
337                : false;
338    }
339    
340    private Map<String, Object> _contextualParameters(Site currentSite)
341    {
342        return new HashMap<>(ImmutableMap.of("siteName", currentSite.getName()));
343    }
344    
345    /**
346     * Builds the query of the criterion tree
347     * @param criterionTree The criterion tree of the service instance
348     * @param userCriteria The user input criteria
349     * @param returnables The returnables of the service instance
350     * @param searchables The searchables of the service instance
351     * @param additionalParameters The values of additional parameters of the service instance
352     * @param currentLang The current lang
353     * @param filterCriterionPredicate A function to filter criterion. Can be null for TruePredicate
354     * @param contextualParameters the search contextual parameters.
355     * @return The query of the criterion tree
356     */
357    public Query buildQuery(
358            AbstractTreeNode<SearchServiceCriterion<?>> criterionTree,
359            Map<String, Object> userCriteria,
360            Collection<Returnable> returnables,
361            Collection<Searchable> searchables,
362            AdditionalParameterValueMap additionalParameters,
363            String currentLang,
364            Predicate<SearchServiceCriterion> filterCriterionPredicate,
365            Map<String, Object> contextualParameters)
366    {
367        Predicate<SearchServiceCriterion> predicate = filterCriterionPredicate != null ? filterCriterionPredicate : crit -> true;
368        Function<SearchServiceCriterion<?>, Query> queryMapper = crit -> singleCriterionToQuery(crit, userCriteria, returnables, searchables, additionalParameters, currentLang, predicate, contextualParameters);
369        return _advancedQueryBuilder.build(criterionTree, queryMapper);
370    }
371    
372    /**
373     * Builds the query of the single criterion
374     * @param criterion The criterion
375     * @param userCriteria The user input criteria
376     * @param returnables The returnables of the service instance
377     * @param searchables The searchables of the service instance
378     * @param additionalParameters The values of additional parameters of the service instance
379     * @param currentLang The current lang
380     * @param filterCriterionPredicate A function to filter criterion
381     * @param contextualParameters the search contextual parameters.
382     * @param <T> Type of the criterion value
383     * @return The query of the single criterion
384     */
385    public <T> Query singleCriterionToQuery(
386            SearchServiceCriterion<T> criterion,
387            Map<String, Object> userCriteria,
388            Collection<Returnable> returnables,
389            Collection<Searchable> searchables,
390            AdditionalParameterValueMap additionalParameters,
391            String currentLang,
392            Predicate<SearchServiceCriterion> filterCriterionPredicate,
393            Map<String, Object> contextualParameters)
394    {
395        SearchServiceCriterionMode mode = criterion.getMode();
396        CriterionWrappedValue val = mode.getValue(criterion, userCriteria, contextualParameters);
397        
398        // The criterion was not filled by the visitor or it is filtered
399        // Put an empty query. It will be ignored by And/OrQuery
400        Query joinedQuery;
401        if (val.getValue() == null || !filterCriterionPredicate.test(criterion))
402        {
403            joinedQuery = EMPTY_QUERY;
404        }
405        else
406        {
407            BiFunction<CriterionWrappedValue, Operator, Query> queryFunctionFromTransformedValAndRealOperator = (transformedVal, realOperator) -> _queryFromTransformedValAndRealOperator(
408                    criterion.getCriterionDefinition(),
409                    transformedVal,
410                    realOperator,
411                    returnables,
412                    searchables,
413                    additionalParameters,
414                    currentLang,
415                    contextualParameters);
416            
417            joinedQuery = _treeMaker.toQuery(val, criterion.getOperator(), queryFunctionFromTransformedValAndRealOperator, currentLang, contextualParameters);
418        }
419        
420        return Optional.ofNullable(joinedQuery).orElse(new MatchNoneQuery());
421    }
422    
423    private <T> Query _queryFromTransformedValAndRealOperator(
424            SearchServiceCriterionDefinition<T> criterionDefinition,
425            CriterionWrappedValue transformedVal,
426            Operator realOperator,
427            Collection<Returnable> returnables,
428            Collection<Searchable> searchables,
429            AdditionalParameterValueMap additionalParameters,
430            String currentLang,
431            Map<String, Object> contextualParameters)
432    {
433        Query queryOnCriterion = _queryOnCriterion(criterionDefinition, transformedVal, realOperator, currentLang, contextualParameters);
434        Optional<Searchable> searchable = criterionDefinition.getSearchable();
435        Query joinedQuery;
436        
437        if (searchable.isPresent())
438        {
439            joinedQuery = searchable.get().joinQuery(queryOnCriterion, criterionDefinition, returnables, additionalParameters).orElse(null);
440        }
441        else
442        {
443            // common => all searchables are concerned
444            List<Query> joinedQueries = searchables.stream()
445                    .map(s -> s.joinQuery(queryOnCriterion, criterionDefinition, returnables, additionalParameters))
446                    .flatMap(Optional::stream)
447                    .toList();
448            joinedQuery = joinedQueries.isEmpty() ? null : new OrQuery(joinedQueries);
449        }
450        return joinedQuery;
451    }
452    
453    private <T> Query _queryOnCriterion(
454            SearchServiceCriterionDefinition<T> criterionDefinition,
455            CriterionWrappedValue transformedVal,
456            Operator realOperator,
457            String currentLang,
458            Map<String, Object> contextualParameters)
459    {
460        Object unwrappedValue = transformedVal.getValue();
461        if (transformedVal.requestEmptyValue())
462        {
463            return transformedVal.hasValue()
464                    ? new OrQuery(criterionDefinition.getEmptyValueQuery(currentLang, contextualParameters), criterionDefinition.getQuery(unwrappedValue, realOperator, currentLang, contextualParameters))
465                    : criterionDefinition.getEmptyValueQuery(currentLang, contextualParameters);
466        }
467        else
468        {
469            return criterionDefinition.getQuery(unwrappedValue, realOperator, currentLang, contextualParameters);
470        }
471    }
472    
473    private void _logTree(Logger logger, AbstractTreeNode<SearchServiceCriterion<?>> tree, Map<String, Object> userCriteria)
474    {
475        if (logger.isDebugEnabled())
476        {
477            Function<SearchServiceCriterion<?>, String> leafStringifier = c ->
478            {
479                String id = c.getName();
480                String value = String.valueOf(userCriteria.get(id));
481                return "{" + id + ": Operator=" + c.getOperator() + ", Mode=" + c.getMode() + ", Value=" + value + ", StaticValue=" + c.getStaticValue() + "}";
482            };
483            logger.debug("\n" + TreePrinter.print(tree, leafStringifier));
484        }
485    }
486    
487    /**
488     * Get the filter query from arguments
489     * @param args the search arguments
490     * @return the filter query
491     */
492    public Query getFilterQuery(SearchComponentArguments args)
493    {
494        SitemapElement currentPage = args.currentPage();
495        Site currentSite = args.currentSite();
496        String currentLang = args.currentLang();
497        SearchServiceInstance serviceInstance = args.serviceInstance();
498        
499        return getFilterQuery(serviceInstance, currentSite, currentPage, currentLang);
500    }
501    
502    /**
503     * Get the filter query
504     * @param serviceInstance the service instance
505     * @param site the site
506     * @param page the page
507     * @param lang the lang
508     * @return the filter query
509     */
510    public Query getFilterQuery(SearchServiceInstance serviceInstance, Site site, SitemapElement page, String lang)
511    {
512        AdditionalParameterValueMap additionalParameterValues = serviceInstance.getAdditionalParameterValues();
513        
514        List<ContextQueriesWrapper> contextQueriesWrappers = serviceInstance.getContexts()
515            .stream()
516            .map(searchCtx -> _createContextQueriesWrapper(searchCtx, site, page, lang))
517            .collect(Collectors.toList());
518        
519        return serviceInstance.getReturnables()
520                .stream()
521                .map(returnable -> returnable.filterReturnedDocumentQuery(contextQueriesWrappers, additionalParameterValues))
522                .collect(OrQuery.collector());
523    }
524    
525    /**
526     * Creates a {@link ContextQueriesWrapper} given one {@link SearchContext} and the current site, page and lang.
527     * @param searchContext The search context
528     * @param currentSite The current site
529     * @param currentPage The current page
530     * @param currentLang The current lang
531     * @return The created wrapper of queries of a {@link SearchContext}
532     */
533    protected ContextQueriesWrapper _createContextQueriesWrapper(SearchContext searchContext, Site currentSite, SitemapElement currentPage, String currentLang)
534    {
535        return new ContextQueriesWrapper(
536                searchContext.getSiteQuery(currentSite),
537                searchContext.getSitemapQuery(currentPage),
538                searchContext.getContextLang(currentLang),
539                searchContext.getTagQuery()
540        );
541    }
542    
543    /**
544     * Retrieves a {@link Map} of contextual parameters
545     * @param args the search component arguments
546     * @return the {@link Map} of contextual parameters
547     */
548    public static Map<String, Object> getSearchComponentContextualParameters(SearchComponentArguments args)
549    {
550        Map<String, Object> contextualParameters = new HashMap<>();
551        
552        contextualParameters.put("searchContexts", args.serviceInstance().getContexts());
553        contextualParameters.put("siteName", args.currentSite().getName());
554        contextualParameters.put("lang", args.currentLang());
555        
556        return contextualParameters;
557    }
558}