001/*
002 *  Copyright 2015 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.cms.search;
017
018import java.util.ArrayList;
019import java.util.Collection;
020import java.util.Collections;
021import java.util.HashMap;
022import java.util.HashSet;
023import java.util.List;
024import java.util.Map;
025import java.util.Optional;
026import java.util.Set;
027import java.util.function.BiFunction;
028import java.util.stream.Collectors;
029
030import org.apache.avalon.framework.component.Component;
031import org.apache.avalon.framework.logger.AbstractLogEnabled;
032import org.apache.avalon.framework.service.ServiceException;
033import org.apache.avalon.framework.service.ServiceManager;
034import org.apache.avalon.framework.service.Serviceable;
035import org.apache.commons.collections4.CollectionUtils;
036import org.apache.commons.lang3.StringUtils;
037
038import org.ametys.cms.contenttype.ContentType;
039import org.ametys.cms.contenttype.ContentTypeExtensionPoint;
040import org.ametys.cms.contenttype.MetadataType;
041import org.ametys.cms.languages.Language;
042import org.ametys.cms.languages.LanguagesManager;
043import org.ametys.cms.search.advanced.AbstractTreeNode;
044import org.ametys.cms.search.advanced.AdvancedQueryBuilder;
045import org.ametys.cms.search.advanced.TreeMaker;
046import org.ametys.cms.search.advanced.TreeMaker.ClientSideCriterionWrapper;
047import org.ametys.cms.search.advanced.WrappedValue;
048import org.ametys.cms.search.advanced.utils.TreePrinter;
049import org.ametys.cms.search.model.SearchCriterion;
050import org.ametys.cms.search.model.SystemSearchCriterion;
051import org.ametys.cms.search.query.AndQuery;
052import org.ametys.cms.search.query.ContentLanguageQuery;
053import org.ametys.cms.search.query.ContentTypeQuery;
054import org.ametys.cms.search.query.MixinTypeQuery;
055import org.ametys.cms.search.query.OrQuery;
056import org.ametys.cms.search.query.Query;
057import org.ametys.cms.search.query.Query.Operator;
058import org.ametys.cms.search.ui.model.SearchUICriterion;
059import org.ametys.cms.search.ui.model.SearchUIModel;
060import org.ametys.cms.search.ui.model.impl.SystemSearchUICriterion;
061
062/**
063 * Builds a {@link Query} object from a user search.
064 */
065public class QueryBuilder extends AbstractLogEnabled implements Component, Serviceable
066{
067    /** The component role. */
068    public static final String ROLE = QueryBuilder.class.getName();
069
070    /** Prefix for id of metadata search criteria */
071    public static final String SEARCH_CRITERIA_METADATA_PREFIX = "metadata-";
072
073    /** Prefix for id of system property search criteria */
074    public static final String SEARCH_CRITERIA_SYSTEM_PREFIX = "property-";
075    
076    /** Key of flag present in contextual parameters to indicate the current search is multilingual */
077    public static final String MULTILINGUAL_SEARCH = "multilingualSearch";
078
079    /** Key of flag present in contextual parameters to indicate the provided value was already escaped */
080    public static final String VALUE_IS_ESCAPED = "isEscapedValue";
081    
082    /** The query default language. */
083    public static final String DEFAULT_LANGUAGE = "en";
084
085    /** The content type extension point. */
086    protected ContentTypeExtensionPoint _cTypeEP;
087
088    /** The languages manager */
089    protected LanguagesManager _languagesManager;
090
091    /** The Advanced tree maker */
092    protected TreeMaker _advancedTreeMaker;
093    
094    /** The advanced query builder */
095    protected AdvancedQueryBuilder _advancedQueryBuilder;
096
097    @Override
098    public void service(ServiceManager serviceManager) throws ServiceException
099    {
100        _cTypeEP = (ContentTypeExtensionPoint) serviceManager.lookup(ContentTypeExtensionPoint.ROLE);
101        _languagesManager = (LanguagesManager) serviceManager.lookup(LanguagesManager.ROLE);
102        _advancedTreeMaker = (TreeMaker) serviceManager.lookup(TreeMaker.ROLE);
103        _advancedQueryBuilder = (AdvancedQueryBuilder) serviceManager.lookup(AdvancedQueryBuilder.ROLE);
104    }
105
106    /**
107     * Build the {@link Query} object.
108     * @param model the search model.
109     * @param searchMode the search mode.
110     * @param createContentTypeQuery True to generate a query based on the content types of the model, and the 'property-contentTypes-eq' if present in values
111     * @param values the user search values.
112     * @param contextualParameters the search contextual parameters.
113     * @return a {@link Query} object representing the search.
114     */
115    @SuppressWarnings("unchecked")
116    public Query build(SearchUIModel model, String searchMode, boolean createContentTypeQuery, Map<String, Object> values, Map<String, Object> contextualParameters)
117    {
118        Map<String, Object> copiedValues = new HashMap<>(values);
119        List<Query> queries = new ArrayList<>();
120
121        Set<String> modelContentTypes = model.getContentTypes(contextualParameters);
122        Set<String> modelExcludedCTypes = model.getExcludedContentTypes(contextualParameters);
123        Map<String, ? extends SearchCriterion> criteria = searchMode.equals("advanced") ? model.getAdvancedCriteria(contextualParameters) : model.getCriteria(contextualParameters);
124
125        Query cTypeQuery = null;
126        if (createContentTypeQuery)
127        {
128            cTypeQuery = createContentTypeQuery(modelContentTypes, copiedValues, (Map<String, SearchCriterion>) criteria, searchMode);
129        }
130        if (cTypeQuery != null)
131        {
132            queries.add(cTypeQuery);
133        }
134
135        if (!modelExcludedCTypes.isEmpty())
136        {
137            // query is on `allContentTypes` field, so do not be redundant and only keep the top supertypes for a readable query
138            queries.add(new ContentTypeQuery(Operator.NE, _getOnlySuperTypes(modelExcludedCTypes)));
139        }
140
141        String language = getCriteriaLanguage((Map<String, SearchUICriterion>) criteria, searchMode, copiedValues, contextualParameters);
142
143        Query mixinQuery = createMixinTypeQuery(modelContentTypes, values, (Map<String, SearchCriterion>) criteria, searchMode);
144        if (mixinQuery != null)
145        {
146            queries.add(mixinQuery);
147        }
148        
149        Map<String, Object> modifiableContextualParameters = new HashMap<>(contextualParameters);
150        if (_isMultilingualSearch(cTypeQuery))
151        {
152            modifiableContextualParameters.put(MULTILINGUAL_SEARCH, true);
153        }
154
155        if ("simple".equals(searchMode))
156        {
157            queries.addAll(getCriteriaQueries((Map<String, SearchUICriterion>) criteria, copiedValues, language, modifiableContextualParameters));
158        }
159        else if ("advanced".equals(searchMode))
160        {
161            Query advQuery = getAdvancedCriteriaQuery((Map<String, SearchCriterion>) criteria, copiedValues, language, modifiableContextualParameters);
162            if (advQuery != null)
163            {
164                queries.add(advQuery);
165            }
166
167            Query contentLanguageQuery = new ContentLanguageQuery(language);
168            queries.add(contentLanguageQuery);
169        }
170
171        return new AndQuery(queries);
172    }
173    
174    private boolean _isMultilingualSearch(Query cTypeQuery)
175    {
176        if (cTypeQuery == null || !(cTypeQuery instanceof ContentTypeQuery))
177        {
178            return false;
179        }
180        
181        for (String cTypeId : ((ContentTypeQuery) cTypeQuery).getIds())
182        {
183            if (!_cTypeEP.getExtension(cTypeId).isMultilingual())
184            {
185                return false;
186            }
187        }
188        
189        // All concerned content types are multilingual
190        return true;
191    }
192
193    private Set<String> _getOnlySuperTypes(Set<String> cTypes)
194    {
195        Set<String> result = new HashSet<>();
196
197        for (String cTypeId : cTypes)
198        {
199            ContentType cType = _cTypeEP.getExtension(cTypeId);
200            if (!_containsAny(cTypes, cType.getSupertypeIds()))
201            {
202                result.add(cTypeId);
203            }
204        }
205
206        return result;
207    }
208
209    // Returns true if at least one of the supertypeIds is in cTypes
210    private boolean _containsAny(Set<String> cTypes, String[] supertypeIds)
211    {
212        for (String supertypeId : supertypeIds)
213        {
214            if (cTypes.contains(supertypeId))
215            {
216                return true;
217            }
218        }
219        return false;
220    }
221
222    /**
223     * Get the language.
224     * @param criteria the list of search criteria
225     * @param searchMode The search mode (advanced or simple)
226     * @param values The user values.
227     * @param contextualParameters The search contextual parameters.
228     * @return the query language.
229     */
230    public String getCriteriaLanguage(Map<String, SearchUICriterion> criteria, String searchMode, Map<String, Object> values, Map<String, Object> contextualParameters)
231    {
232        String langValue = null;
233
234        // First search language in search criteria
235        for (SearchCriterion criterion : criteria.values())
236        {
237            if (criterion instanceof SystemSearchCriterion)
238            {
239                SystemSearchUICriterion sysCrit = (SystemSearchUICriterion) criterion;
240                if (sysCrit.getSystemPropertyId().equals("contentLanguage"))
241                {
242                    if (sysCrit.isHidden())
243                    {
244                        // Use the default value
245                        langValue = (String) sysCrit.getDefaultValue();
246                    }
247                    else
248                    {
249                        // Use the user input
250                        langValue = (String) values.get(sysCrit.getId());
251                    }
252                    break;
253                }
254            }
255        }
256
257        if (StringUtils.isEmpty(langValue) || "CURRENT".equals(langValue))
258        {
259            // If empty, get language from the search contextual parameters (for instance, sent by the tool).
260            langValue = (String) contextualParameters.get("language");
261        }
262
263        if (StringUtils.isEmpty(langValue))
264        {
265            // If no language found: fall back to default.
266            langValue = getDefaultLanguage();
267        }
268
269        return langValue;
270    }
271
272    /**
273     * Get the default language for search
274     * @return The default language
275     */
276    protected String getDefaultLanguage()
277    {
278        Map<String, Language> availableLanguages = _languagesManager.getAvailableLanguages();
279        if (availableLanguages.containsKey(DEFAULT_LANGUAGE))
280        {
281            return DEFAULT_LANGUAGE;
282        }
283
284        return availableLanguages.size() > 0 ? availableLanguages.keySet().iterator().next() : DEFAULT_LANGUAGE;
285    }
286
287    /**
288     * Create a content type query.
289     * @param modelContentTypes the content types to search on.
290     * @param values the user search values.
291     * @param criteria the list of search criteria
292     * @param searchMode The search mode (advanced or simple)
293     * @return the content type {@link Query}.
294     */
295    @SuppressWarnings("unchecked")
296    protected Query createContentTypeQuery(Set<String> modelContentTypes, Map<String, Object> values, Map<String, SearchCriterion> criteria, String searchMode)
297    {
298        Operator op = Operator.EQ;
299        String systemCriterionId = _findSystemCriterionId("contentTypes", criteria);
300        Object cTypeParam = _getAndRemoveValueForSytemCriterionAndEqOperator(systemCriterionId, values, searchMode);
301        
302        boolean criterionIsMultiple = Optional.ofNullable(systemCriterionId)
303                .map(criteria::get)
304                .map(SearchCriterion::isMultiple)
305                .orElse(false);
306        boolean emptyCTypeParam = criterionIsMultiple && CollectionUtils.isEmpty((Collection< ? >) cTypeParam) || !criterionIsMultiple && StringUtils.isEmpty((String) cTypeParam);
307
308        if (emptyCTypeParam)
309        {
310            return createContentTypeOrMixinQuery(modelContentTypes, values, criteria, searchMode);
311        }
312        else if (criterionIsMultiple) // non-empty and multiple
313        {
314            return new ContentTypeQuery(op, (Collection<String>) cTypeParam);
315        }
316        else // non-empty and non-multiple
317        {
318            return new ContentTypeQuery(op, (String) cTypeParam);
319        }
320    }
321
322    /**
323     * Create a mixin type query.
324     * @param mixinTypes the mixin types to search on.
325     * @param values the user search values.
326     * @param criteria the list of search criteria
327     * @param searchMode The search mode (advanced or simple)
328     * @return the mixin type {@link Query}.
329     */
330    @SuppressWarnings("unchecked")
331    protected Query createMixinTypeQuery(Set<String> mixinTypes, Map<String, Object> values, Map<String, SearchCriterion> criteria, String searchMode)
332    {
333        String systemCriterionId = _findSystemCriterionId("mixin", criteria);
334        Object mixinParam = _getAndRemoveValueForSytemCriterionAndEqOperator(systemCriterionId, values, searchMode);
335        
336        boolean criterionIsMultiple = Optional.ofNullable(systemCriterionId)
337                .map(criteria::get)
338                .map(SearchCriterion::isMultiple)
339                .orElse(false);
340        boolean emptyMixinParam = criterionIsMultiple && CollectionUtils.isEmpty((Collection< ? >) mixinParam) || !criterionIsMultiple && StringUtils.isEmpty((String) mixinParam);
341
342        if (emptyMixinParam)
343        {
344            return null;
345        }
346        else if (criterionIsMultiple) // non-empty and multiple
347        {
348            return new MixinTypeQuery((Collection<String>) mixinParam);
349        }
350        else // non-empty and non-multiple
351        {
352            return new MixinTypeQuery((String) mixinParam);
353        }
354    }
355
356    /**
357     * Create a content type or mixin query.
358     * @param modelContentTypes the content types or mixins to search on.
359     * @param values the user search values.
360     * @param criteria the list of search criteria
361     * @param searchMode The search mode (advanced or simple)
362     * @return the content type {@link Query}.
363     */
364    protected Query createContentTypeOrMixinQuery(Set<String> modelContentTypes, Map<String, Object> values, Map<String, SearchCriterion> criteria, String searchMode)
365    {
366        String systemCriterionId = _findSystemCriterionId("contentTypeOrMixin", criteria);
367        Object cTypeOrMixinParam = _getAndRemoveValueForSytemCriterionAndEqOperator(systemCriterionId, values, searchMode);
368        
369        boolean criterionIsMultiple = Optional.ofNullable(systemCriterionId)
370                .map(criteria::get)
371                .map(SearchCriterion::isMultiple)
372                .orElse(false);
373        return createContentTypeOrMixinQuery(modelContentTypes, cTypeOrMixinParam, criterionIsMultiple);
374    }
375    
376    /**
377     * Create a content type or mixin query.
378     * @param modelContentTypes the content types or mixins to search on.
379     * @param cTypeOrMixinParam The contentTypes or mixin parameter on which you want to build the Query
380     * @param criterionIsMultiple <code>true</code> if the given parameter is a value for a multiple criterion
381     * @return the content type {@link Query}.
382     */
383    @SuppressWarnings("unchecked")
384    public Query createContentTypeOrMixinQuery(Set<String> modelContentTypes, Object cTypeOrMixinParam, boolean criterionIsMultiple)
385    {
386        boolean emptyCTypeOrMixinParam = criterionIsMultiple && CollectionUtils.isEmpty((Collection< ? >) cTypeOrMixinParam) || !criterionIsMultiple && StringUtils.isEmpty((String) cTypeOrMixinParam);
387        
388        if (emptyCTypeOrMixinParam && modelContentTypes != null && !modelContentTypes.isEmpty()) // empty and non-empty model contentTypes
389        {
390            return _contentTypeQueryOrMixinQuery(modelContentTypes);
391        }
392        else if (emptyCTypeOrMixinParam) // empty and empty model contentTypes
393        {
394            return null;
395        }
396        else if (criterionIsMultiple) // non-empty and multiple
397        {
398            return _contentTypeQueryOrMixinQuery((Collection<String>) cTypeOrMixinParam);
399        }
400        else // non-empty and non-multiple
401        {
402            String cTypeOrMixinParamStr = (String) cTypeOrMixinParam;
403            return _cTypeEP.getExtension(cTypeOrMixinParamStr).isMixin() ? new MixinTypeQuery(cTypeOrMixinParamStr)
404                                                                         : new ContentTypeQuery(cTypeOrMixinParamStr);
405        }
406    }
407
408    private Query _contentTypeQueryOrMixinQuery(Collection<String> contentTypes)
409    {
410        Collection<String> onlyMixins = contentTypes.stream()
411                .filter(ct -> _cTypeEP.getExtension(ct).isMixin())
412                .collect(Collectors.toList());
413        Collection<String> onlyContentTypes = contentTypes.stream()
414                .filter(ct -> !_cTypeEP.getExtension(ct).isMixin())
415                .collect(Collectors.toList());
416        if (onlyMixins.isEmpty())
417        {
418            return new ContentTypeQuery(contentTypes);
419        }
420        else if (onlyContentTypes.isEmpty())
421        {
422            return new MixinTypeQuery(contentTypes);
423        }
424        else
425        {
426            return new OrQuery(new ContentTypeQuery(onlyContentTypes), new MixinTypeQuery(onlyMixins));
427        }
428    }
429    
430    private Object _getAndRemoveValueForSytemCriterionAndEqOperator(String systemCriterionId, Map<String, Object> values, String searchMode)
431    {
432        if (systemCriterionId == null)
433        {
434            return null;
435        }
436        
437        if ("simple".equals(searchMode))
438        {
439            return values.remove(systemCriterionId);
440        }
441        
442        // advanced search mode
443        String type = (String) values.get("type");
444        final String eqOperator = Operator.EQ.getName();
445        if ("criterion".equals(type) && systemCriterionId.equals(values.get("id")) && eqOperator.equals(values.get("op"))) // advanced mode with a unique criterion node
446        {
447            Object result = values.get("value");
448            values.clear();
449            return result;
450        }
451        else if (_isFlatAndExpression(type, values)) // advanced mode with an AND operator on only criteria (no complicated nested expressions)
452        {
453            @SuppressWarnings("unchecked")
454            List<Map<String, Object>> expressions = (List<Map<String, Object>>) values.get("expressions");
455            Map<String, Object> systemPropertyExpr = expressions.stream()
456                    .filter(exp -> systemCriterionId.equals(exp.get("id")))
457                    .filter(exp -> eqOperator.equals(exp.get("op")))
458                    .findFirst()
459                    .orElse(null);
460            if (systemPropertyExpr != null)
461            {
462                expressions.remove(systemPropertyExpr);
463                return systemPropertyExpr.get("value");
464            }
465        }
466        
467        return null;
468    }
469    
470    private String _findSystemCriterionId(String systemPropertyId, Map<String, SearchCriterion> criteria)
471    {
472        for (SearchCriterion criterion : criteria.values())
473        {
474            if (criterion instanceof SystemSearchCriterion && criterion.getOperator() == Operator.EQ)
475            {
476                SystemSearchCriterion systemCrit = (SystemSearchCriterion) criterion;
477                if (!systemCrit.isJoined() && systemCrit.getSystemPropertyId().equals(systemPropertyId))
478                {
479                    return criterion.getId();
480                }
481            }
482        }
483        
484        return null;
485    }
486    
487    @SuppressWarnings("unchecked")
488    private boolean _isFlatAndExpression(String rootType, Map<String, Object> values)
489    {
490        return "and".equalsIgnoreCase(rootType)
491                && ((List<Map<String, Object>>) values.get("expressions"))
492                        .stream()
493                        .allMatch(exp -> "criterion".equals(exp.get("type")));
494    }
495
496    /**
497     * Get the list of query on criteria.
498     * @param criteria the list of search criteria
499     * @param values The submitted values
500     * @param language The query language.
501     * @param contextualParameters The contextual parameters
502     * @return The criteria {@link Query}.
503     */
504    protected List<Query> getCriteriaQueries(Map<String, SearchUICriterion> criteria, Map<String, Object> values, String language, Map<String, Object> contextualParameters)
505    {
506        List<Query> queries = new ArrayList<>();
507
508        for (String id : criteria.keySet())
509        {
510            SearchUICriterion criterion = criteria.get(id);
511            Object submitValue = values.get(id);
512            
513            // If the criterion is hidden, take the default value (fixed in the search model).
514            // Otherwise take the standard user value.
515            Object value = criterion.isHidden() ? criterion.getDefaultValue() : submitValue;
516
517            if (value != null || criterion.getOperator() == Operator.EXISTS)
518            {
519                Query query = criterion.getQuery(value, values, language, contextualParameters);
520                if (query != null)
521                {
522                    queries.add(query);
523                }
524            }
525        }
526
527        return queries;
528    }
529
530    /**
531     * Get a complex Query from the advanced search values.
532     * @param criteria the list of search criteria
533     * @param values The submitted values
534     * @param language The query language.
535     * @param contextualParameters The contextual parameters
536     * @return The criteria {@link Query}.
537     */
538    protected Query getAdvancedCriteriaQuery(Map<String, SearchCriterion> criteria, Map<String, Object> values, String language, Map<String, Object> contextualParameters)
539    {
540        AbstractTreeNode<ValuedSearchCriterion> tree = _createTreeNode(criteria, values);
541        if (tree == null)
542        {
543            return null;
544        }
545        
546        if (getLogger().isDebugEnabled())
547        {
548            getLogger().debug("\n" + TreePrinter.print(tree, c -> "{" + c._searchCriterion.getId() + ": Operator=" + c._op + ", Value=" + c._value + "}"));
549        }
550        return _advancedQueryBuilder.build(tree, valuedCrit -> valuedCrit.toQuery(language, contextualParameters));
551    }
552    
553    private AbstractTreeNode<ValuedSearchCriterion> _createTreeNode(Map<String, SearchCriterion> criteria, Map<String, Object> values)
554    {
555        return _advancedTreeMaker.create(values, clientSideCrit -> new ValuedSearchCriterion(criteria.get(clientSideCrit.getId()), clientSideCrit, _advancedTreeMaker));
556    }
557    
558    private static final class ValuedSearchCriterion
559    {
560        SearchCriterion _searchCriterion;
561        String _op;
562        Object _value;
563        private TreeMaker _advancedTreeMaker;
564
565        ValuedSearchCriterion(SearchCriterion searchCriterion, ClientSideCriterionWrapper clientSideCriterion, TreeMaker advancedTreeMaker)
566        {
567            _searchCriterion = searchCriterion;
568            _op = clientSideCriterion.getStringOperator();
569            _value = clientSideCriterion.getValue();
570            _advancedTreeMaker = advancedTreeMaker;
571        }
572        
573        Query toQuery(String language, Map<String, Object> contextualParameters)
574        {
575            BiFunction<WrappedValue, Operator, Query> toQuery = (transformedVal, realOperator) -> _searchCriterion.getQuery(transformedVal.getValue(), realOperator, Collections.emptyMap(), language, contextualParameters);
576            // Hack of the death to get the value of reference attribute and use "string" operators (CMS-10773)
577            WrappedValue wrappedValue = new WrappedValue(
578                _value != null && _searchCriterion.getType() == MetadataType.REFERENCE
579                ? Optional.of(_value).filter(Map.class::isInstance).map(v -> ((Map<String, String>) v).get("value")).orElse(StringUtils.EMPTY)
580                : _value
581            );
582            return _advancedTreeMaker.toQuery(wrappedValue, _op, toQuery, language, contextualParameters);
583        }
584    }
585
586}