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    @SuppressWarnings("unchecked")
294    protected Query createContentTypeQuery(Set<String> modelContentTypes, Map<String, Object> values, Map<String, SearchCriterion> criteria, String searchMode)
295    {
296        Operator op = Operator.EQ;
297        String systemCriterionId = _findSystemCriterionId("contentTypes", criteria);
298        Object cTypeParam = _getAndRemoveValueForSytemCriterionAndEqOperator(systemCriterionId, values, searchMode);
299        
300        boolean criterionIsMultiple = Optional.ofNullable(systemCriterionId)
301                .map(criteria::get)
302                .map(SearchCriterion::isMultiple)
303                .orElse(false);
304        boolean emptyCTypeParam = criterionIsMultiple && CollectionUtils.isEmpty((Collection< ? >) cTypeParam) || !criterionIsMultiple && StringUtils.isEmpty((String) cTypeParam);
305
306        if (emptyCTypeParam)
307        {
308            return createContentTypeOrMixinQuery(modelContentTypes, values, criteria, searchMode);
309        }
310        else if (criterionIsMultiple) // non-empty and multiple
311        {
312            return new ContentTypeQuery(op, (Collection<String>) cTypeParam);
313        }
314        else // non-empty and non-multiple
315        {
316            return new ContentTypeQuery(op, (String) cTypeParam);
317        }
318    }
319
320    /**
321     * Create a mixin type query.
322     * @param mixinTypes the mixin types to search on.
323     * @param values the user search values.
324     * @param criteria the list of search criteria
325     * @param searchMode The search mode (advanced or simple)
326     * @return the mixin type {@link Query}.
327     */
328    @SuppressWarnings("unchecked")
329    protected Query createMixinTypeQuery(Set<String> mixinTypes, Map<String, Object> values, Map<String, SearchCriterion> criteria, String searchMode)
330    {
331        String systemCriterionId = _findSystemCriterionId("mixin", criteria);
332        Object mixinParam = _getAndRemoveValueForSytemCriterionAndEqOperator(systemCriterionId, values, searchMode);
333        
334        boolean criterionIsMultiple = Optional.ofNullable(systemCriterionId)
335                .map(criteria::get)
336                .map(SearchCriterion::isMultiple)
337                .orElse(false);
338        boolean emptyMixinParam = criterionIsMultiple && CollectionUtils.isEmpty((Collection< ? >) mixinParam) || !criterionIsMultiple && StringUtils.isEmpty((String) mixinParam);
339
340        if (emptyMixinParam)
341        {
342            return null;
343        }
344        else if (criterionIsMultiple) // non-empty and multiple
345        {
346            return new MixinTypeQuery((Collection<String>) mixinParam);
347        }
348        else // non-empty and non-multiple
349        {
350            return new MixinTypeQuery((String) mixinParam);
351        }
352    }
353
354    /**
355     * Create a content type or mixin query.
356     * @param modelContentTypes the content types or mixins to search on.
357     * @param values the user search values.
358     * @param criteria the list of search criteria
359     * @param searchMode The search mode (advanced or simple)
360     * @return the content type {@link Query}.
361     */
362    protected Query createContentTypeOrMixinQuery(Set<String> modelContentTypes, Map<String, Object> values, Map<String, SearchCriterion> criteria, String searchMode)
363    {
364        String systemCriterionId = _findSystemCriterionId("contentTypeOrMixin", criteria);
365        Object cTypeOrMixinParam = _getAndRemoveValueForSytemCriterionAndEqOperator(systemCriterionId, values, searchMode);
366        
367        boolean criterionIsMultiple = Optional.ofNullable(systemCriterionId)
368                .map(criteria::get)
369                .map(SearchCriterion::isMultiple)
370                .orElse(false);
371        return createContentTypeOrMixinQuery(modelContentTypes, cTypeOrMixinParam, criterionIsMultiple);
372    }
373    
374    /**
375     * Create a content type or mixin query.
376     * @param modelContentTypes the content types or mixins to search on.
377     * @param cTypeOrMixinParam The contentTypes or mixin parameter on which you want to build the Query
378     * @param criterionIsMultiple <code>true</code> if the given parameter is a value for a multiple criterion
379     * @return the content type {@link Query}.
380     */
381    @SuppressWarnings("unchecked")
382    public Query createContentTypeOrMixinQuery(Set<String> modelContentTypes, Object cTypeOrMixinParam, boolean criterionIsMultiple)
383    {
384        boolean emptyCTypeOrMixinParam = criterionIsMultiple && CollectionUtils.isEmpty((Collection< ? >) cTypeOrMixinParam) || !criterionIsMultiple && StringUtils.isEmpty((String) cTypeOrMixinParam);
385        
386        if (emptyCTypeOrMixinParam && modelContentTypes != null && !modelContentTypes.isEmpty()) // empty and non-empty model contentTypes
387        {
388            return _contentTypeQueryOrMixinQuery(modelContentTypes);
389        }
390        else if (emptyCTypeOrMixinParam) // empty and empty model contentTypes
391        {
392            return null;
393        }
394        else if (criterionIsMultiple) // non-empty and multiple
395        {
396            return _contentTypeQueryOrMixinQuery((Collection<String>) cTypeOrMixinParam);
397        }
398        else // non-empty and non-multiple
399        {
400            String cTypeOrMixinParamStr = (String) cTypeOrMixinParam;
401            return _cTypeEP.getExtension(cTypeOrMixinParamStr).isMixin() ? new MixinTypeQuery(cTypeOrMixinParamStr)
402                                                                         : new ContentTypeQuery(cTypeOrMixinParamStr);
403        }
404    }
405
406    private Query _contentTypeQueryOrMixinQuery(Collection<String> contentTypes)
407    {
408        Collection<String> onlyMixins = contentTypes.stream()
409                .filter(ct -> _cTypeEP.getExtension(ct).isMixin())
410                .collect(Collectors.toList());
411        Collection<String> onlyContentTypes = contentTypes.stream()
412                .filter(ct -> !_cTypeEP.getExtension(ct).isMixin())
413                .collect(Collectors.toList());
414        if (onlyMixins.isEmpty())
415        {
416            return new ContentTypeQuery(contentTypes);
417        }
418        else if (onlyContentTypes.isEmpty())
419        {
420            return new MixinTypeQuery(contentTypes);
421        }
422        else
423        {
424            return new OrQuery(new ContentTypeQuery(onlyContentTypes), new MixinTypeQuery(onlyMixins));
425        }
426    }
427    
428    private Object _getAndRemoveValueForSytemCriterionAndEqOperator(String systemCriterionId, Map<String, Object> values, String searchMode)
429    {
430        if (systemCriterionId == null)
431        {
432            return null;
433        }
434        
435        if ("simple".equals(searchMode))
436        {
437            return values.remove(systemCriterionId);
438        }
439        
440        // advanced search mode
441        String type = (String) values.get("type");
442        final String eqOperator = Operator.EQ.getName();
443        if ("criterion".equals(type) && systemCriterionId.equals(values.get("id")) && eqOperator.equals(values.get("op"))) // 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()
454                    .filter(exp -> systemCriterionId.equals(exp.get("id")))
455                    .filter(exp -> eqOperator.equals(exp.get("op")))
456                    .findFirst()
457                    .orElse(null);
458            if (systemPropertyExpr != null)
459            {
460                expressions.remove(systemPropertyExpr);
461                return systemPropertyExpr.get("value");
462            }
463        }
464        
465        return null;
466    }
467    
468    private String _findSystemCriterionId(String systemPropertyId, Map<String, SearchCriterion> criteria)
469    {
470        for (SearchCriterion criterion : criteria.values())
471        {
472            if (criterion instanceof SystemSearchCriterion && criterion.getOperator() == Operator.EQ)
473            {
474                SystemSearchCriterion systemCrit = (SystemSearchCriterion) criterion;
475                if (!systemCrit.isJoined() && systemCrit.getSystemPropertyId().equals(systemPropertyId))
476                {
477                    return criterion.getId();
478                }
479            }
480        }
481        
482        return null;
483    }
484    
485    @SuppressWarnings("unchecked")
486    private boolean _isFlatAndExpression(String rootType, Map<String, Object> values)
487    {
488        return "and".equalsIgnoreCase(rootType)
489                && ((List<Map<String, Object>>) values.get("expressions"))
490                        .stream()
491                        .allMatch(exp -> "criterion".equals(exp.get("type")));
492    }
493
494    /**
495     * Get the list of query on criteria.
496     * @param criteria the list of search criteria
497     * @param values The submitted values
498     * @param language The query language.
499     * @param contextualParameters The contextual parameters
500     * @return The criteria {@link Query}.
501     */
502    protected List<Query> getCriteriaQueries(Map<String, SearchUICriterion> criteria, Map<String, Object> values, String language, Map<String, Object> contextualParameters)
503    {
504        List<Query> queries = new ArrayList<>();
505
506        for (String id : criteria.keySet())
507        {
508            SearchUICriterion criterion = criteria.get(id);
509            Object submitValue = values.get(id);
510            
511            // If the criterion is hidden, take the default value (fixed in the search model).
512            // Otherwise take the standard user value.
513            Object value = criterion.isHidden() ? criterion.getDefaultValue() : submitValue;
514
515            if (value != null || criterion.getOperator() == Operator.EXISTS)
516            {
517                Query query = criterion.getQuery(value, values, language, contextualParameters);
518                if (query != null)
519                {
520                    queries.add(query);
521                }
522            }
523        }
524
525        return queries;
526    }
527
528    /**
529     * Get a complex Query from the advanced search values.
530     * @param criteria the list of search criteria
531     * @param values The submitted values
532     * @param language The query language.
533     * @param contextualParameters The contextual parameters
534     * @return The criteria {@link Query}.
535     */
536    protected Query getAdvancedCriteriaQuery(Map<String, SearchCriterion> criteria, Map<String, Object> values, String language, Map<String, Object> contextualParameters)
537    {
538        AbstractTreeNode<ValuedSearchCriterion> tree = _createTreeNode(criteria, values);
539        if (tree == null)
540        {
541            return null;
542        }
543        
544        if (getLogger().isDebugEnabled())
545        {
546            getLogger().debug("\n" + TreePrinter.print(tree, c -> "{" + c._searchCriterion.getId() + ": Operator=" + c._op + ", Value=" + c._value + "}"));
547        }
548        return _advancedQueryBuilder.build(tree, valuedCrit -> valuedCrit.toQuery(language, contextualParameters));
549    }
550    
551    private AbstractTreeNode<ValuedSearchCriterion> _createTreeNode(Map<String, SearchCriterion> criteria, Map<String, Object> values)
552    {
553        return _advancedTreeMaker.create(values, clientSideCrit -> new ValuedSearchCriterion(criteria.get(clientSideCrit.getId()), clientSideCrit, _advancedTreeMaker));
554    }
555    
556    private static final class ValuedSearchCriterion
557    {
558        SearchCriterion _searchCriterion;
559        String _op;
560        Object _value;
561        private TreeMaker _advancedTreeMaker;
562
563        ValuedSearchCriterion(SearchCriterion searchCriterion, ClientSideCriterionWrapper clientSideCriterion, TreeMaker advancedTreeMaker)
564        {
565            _searchCriterion = searchCriterion;
566            _op = clientSideCriterion.getStringOperator();
567            _value = clientSideCriterion.getValue();
568            _advancedTreeMaker = advancedTreeMaker;
569        }
570        
571        Query toQuery(String language, Map<String, Object> contextualParameters)
572        {
573            BiFunction<Object, Operator, Query> toQuery = (transformedVal, realOperator) -> _searchCriterion.getQuery(transformedVal, realOperator, Collections.emptyMap(), language, contextualParameters);
574            return _advancedTreeMaker.toQuery(_value, _op, toQuery, language, contextualParameters);
575        }
576    }
577
578}