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