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