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