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