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.Collections;
020import java.util.List;
021import java.util.Map;
022import java.util.Set;
023
024import org.apache.avalon.framework.component.Component;
025import org.apache.avalon.framework.logger.AbstractLogEnabled;
026import org.apache.avalon.framework.service.ServiceException;
027import org.apache.avalon.framework.service.ServiceManager;
028import org.apache.avalon.framework.service.Serviceable;
029import org.apache.commons.lang3.StringUtils;
030
031import org.ametys.cms.contenttype.ContentTypeExtensionPoint;
032import org.ametys.cms.languages.Language;
033import org.ametys.cms.languages.LanguagesManager;
034import org.ametys.cms.search.model.SearchCriterion;
035import org.ametys.cms.search.model.SystemSearchCriterion;
036import org.ametys.cms.search.query.AndQuery;
037import org.ametys.cms.search.query.ContentLanguageQuery;
038import org.ametys.cms.search.query.ContentTypeOrMixinTypeQuery;
039import org.ametys.cms.search.query.ContentTypeQuery;
040import org.ametys.cms.search.query.MixinTypeQuery;
041import org.ametys.cms.search.query.NotQuery;
042import org.ametys.cms.search.query.OrQuery;
043import org.ametys.cms.search.query.Query;
044import org.ametys.cms.search.query.Query.Operator;
045import org.ametys.cms.search.ui.model.SearchUICriterion;
046import org.ametys.cms.search.ui.model.SearchUIModel;
047import org.ametys.cms.search.ui.model.impl.SystemSearchUICriterion;
048
049/**
050 * Builds a {@link Query} object from a user search.
051 */
052public class QueryBuilder extends AbstractLogEnabled implements Component, Serviceable
053{
054    /** The component role. */
055    public static final String ROLE = QueryBuilder.class.getName();
056    
057    /** Prefix for id of metadata search criteria */
058    public static final String SEARCH_CRITERIA_METADATA_PREFIX = "metadata-";
059    /** Prefix for id of system property search criteria */
060    public static final String SEARCH_CRITERIA_SYSTEM_PREFIX = "property-";
061    
062    /** The query default language. */
063    public static final String DEFAULT_LANGUAGE = "en";
064    
065    /** The content type extension point. */
066    protected ContentTypeExtensionPoint _cTypeEP;
067    /** The languages manager */
068    protected LanguagesManager _languagesManager;
069    
070    @Override
071    public void service(ServiceManager serviceManager) throws ServiceException
072    {
073        _cTypeEP = (ContentTypeExtensionPoint) serviceManager.lookup(ContentTypeExtensionPoint.ROLE);
074        _languagesManager = (LanguagesManager) serviceManager.lookup(LanguagesManager.ROLE);
075    }
076    
077    /**
078     * Build the {@link Query} object.
079     * @param model the search model.
080     * @param searchMode the search mode.
081     * @param values the user search values.
082     * @param contextualParameters the search contextual parameters.
083     * @return a {@link Query} object representing the search.
084     */
085    public Query build(SearchUIModel model, String searchMode, Map<String, Object> values, Map<String, Object> contextualParameters)
086    {
087        List<Query> queries = new ArrayList<>();
088        
089        Set<String> contentTypes = model.getContentTypes(contextualParameters);
090        Set<String> excludedCTypes = model.getExcludedContentTypes(contextualParameters);
091        
092        // TODO Remove "contentType-eq" and find the criterion by class (like it's done with language?)
093        Query cTypeQuery = createContentTypeQuery(contentTypes, values, contextualParameters);
094        if (cTypeQuery != null)
095        {
096            queries.add(cTypeQuery);
097        }
098        
099        if (!excludedCTypes.isEmpty())
100        {
101            queries.add(new ContentTypeQuery(Operator.NE, excludedCTypes));
102        }
103        
104        String language = getCriteriaLanguage(model, searchMode, values, contextualParameters);
105        
106        // TODO Remove "mixin-eq" and find the criterion by class (like it's done with language?)
107        Query mixinQuery = createMixinTypeQuery(contentTypes, values, contextualParameters);
108        if (mixinQuery != null)
109        {
110            queries.add(mixinQuery);
111        }
112        
113        if ("simple".equals(searchMode))
114        {
115            queries.addAll(getCriteriaQueries(model, values, language, contextualParameters));
116        }
117        else if ("advanced".equals(searchMode))
118        {
119            Query advQuery = getAdvancedCriteriaQuery(model, values, language, contextualParameters);
120            if (advQuery != null)
121            {
122                queries.add(advQuery);
123            }
124            
125            Query contentLanguageQuery = new ContentLanguageQuery(language);
126            queries.add(contentLanguageQuery);
127        }
128        
129        return new AndQuery(queries);
130    }
131    
132    /**
133     * Get the language.
134     * @param model The search model.
135     * @param searchMode The search mode (advanced or simple)
136     * @param values The user values.
137     * @param contextualParameters The search contextual parameters.
138     * @return the query language.
139     */
140    protected String getCriteriaLanguage(SearchUIModel model, String searchMode, Map<String, Object> values, Map<String, Object> contextualParameters)
141    {
142        String langValue = null; 
143        
144        Map<String, SearchUICriterion> criteria = searchMode.equals("advanced") ? model.getAdvancedCriteria(contextualParameters) : model.getCriteria(contextualParameters);
145        // First search language in search criteria
146        for (SearchCriterion criterion : criteria.values())
147        {
148            if (criterion instanceof SystemSearchCriterion)
149            {
150                SystemSearchUICriterion sysCrit = (SystemSearchUICriterion) criterion;
151                if (sysCrit.getSystemPropertyId().equals("contentLanguage"))
152                {
153                    if (sysCrit.isHidden())
154                    {
155                        // Use the default value
156                        langValue = (String) sysCrit.getDefaultValue();
157                    }
158                    else
159                    {
160                        // Use the user input
161                        langValue = (String) values.get(sysCrit.getId());
162                    }
163                    break;
164                }
165            }
166        }
167        
168        if (StringUtils.isEmpty(langValue) || "CURRENT".equals(langValue))
169        {
170            // If empty, get language from the search contextual parameters (for instance, sent by the tool).
171            langValue = (String) contextualParameters.get("language");
172        }
173        
174        if (StringUtils.isEmpty(langValue))
175        {
176            // If no language found: fall back to default.
177            langValue = getDefaultLanguage();
178        }
179
180        return langValue;
181    }
182    
183    /**
184     * Get the default language for search
185     * @return The default language
186     */
187    protected String getDefaultLanguage()
188    {
189        Map<String, Language> availableLanguages = _languagesManager.getAvailableLanguages();
190        if (availableLanguages.containsKey(DEFAULT_LANGUAGE))
191        {
192            return DEFAULT_LANGUAGE;
193        }
194        
195        return availableLanguages.size() > 0 ? availableLanguages.keySet().iterator().next() : DEFAULT_LANGUAGE;
196    }
197    
198    
199    /**
200     * Create a content type query.
201     * @param contentTypes the content types to search on.
202     * @param values the user search values.
203     * @param contextualParameters the search contextual parameters.
204     * @return the content type {@link Query}.
205     */
206    protected Query createContentTypeQuery(Set<String> contentTypes, Map<String, Object> values, Map<String, Object> contextualParameters)
207    {
208        return createContentTypeQuery(contentTypes, values, contextualParameters, false);
209    }
210
211    /**
212     * Create a content type query.
213     * @param contentTypes the content types to search on.
214     * @param values the user search values.
215     * @param contextualParameters the search contextual parameters.
216     * @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.
217     * @return the content type {@link Query}.
218     */
219    @SuppressWarnings("unchecked")
220    protected Query createContentTypeQuery(Set<String> contentTypes, Map<String, Object> values, Map<String, Object> contextualParameters, boolean exclude)
221    {
222        Object cTypeParam = values.get(SEARCH_CRITERIA_SYSTEM_PREFIX + "contentType-eq");
223        
224        if (cTypeParam == null)
225        {
226            return createContentTypeOrMixinQuery(contentTypes, values, contextualParameters);
227        }
228        else
229        {
230            Operator op = exclude ? Operator.NE : Operator.EQ;
231            if (cTypeParam instanceof String && StringUtils.isNotEmpty((String) cTypeParam))
232            {
233                return new ContentTypeQuery(op, StringUtils.split((String) cTypeParam, ','));
234            }
235            else if (cTypeParam instanceof List<?>)
236            {
237                return new ContentTypeQuery(op, (List<String>) cTypeParam);
238            }
239            else if (contentTypes != null && !contentTypes.isEmpty())
240            {
241                return new ContentTypeQuery(op, contentTypes);
242            }
243            else
244            {
245                return null;
246            }
247        }
248    }
249    
250    /**
251     * Create a mixin type query.
252     * @param mixinTypes the mixin types to search on.
253     * @param values the user search values.
254     * @param contextualParameters the search contextual parameters.
255     * @return the mixin type {@link Query}.
256     */
257    @SuppressWarnings("unchecked")
258    protected Query createMixinTypeQuery(Set<String> mixinTypes, Map<String, Object> values, Map<String, Object> contextualParameters)
259    {
260        Object mixinParam = values.get(SEARCH_CRITERIA_SYSTEM_PREFIX + "mixin-eq");
261        
262        if (mixinParam instanceof String && StringUtils.isNotEmpty((String) mixinParam))
263        {
264            return new MixinTypeQuery((String) mixinParam);
265        }
266        else if (mixinParam != null && mixinParam instanceof List<?>)
267        {
268            return new MixinTypeQuery((List<String>) mixinParam);
269        }
270        else
271        {
272            return null;
273        }
274    }
275    
276    /**
277     * Create a content type or mixin query.
278     * @param contentTypes the content types or mixins to search on.
279     * @param values the user search values.
280     * @param contextualParameters the search contextual parameters.
281     * @return the content type {@link Query}.
282     */
283    @SuppressWarnings("unchecked")
284    protected Query createContentTypeOrMixinQuery(Set<String> contentTypes, Map<String, Object> values, Map<String, Object> contextualParameters)
285    {
286        Object cTypeOrMixinParam = values.get(SEARCH_CRITERIA_SYSTEM_PREFIX + "contentTypeOrMixin-eq");
287        
288        if (cTypeOrMixinParam instanceof String && StringUtils.isNotEmpty((String) cTypeOrMixinParam))
289        {
290            return new ContentTypeOrMixinTypeQuery((String) cTypeOrMixinParam);
291        }
292        else if (cTypeOrMixinParam != null && cTypeOrMixinParam instanceof List<?>)
293        {
294            return new ContentTypeOrMixinTypeQuery((List<String>) cTypeOrMixinParam);
295        }
296        else if (contentTypes != null && !contentTypes.isEmpty())
297        {
298            return new ContentTypeOrMixinTypeQuery(contentTypes);
299        }
300        else
301        {
302            return null;
303        }
304    }
305    
306    /**
307     * Get the list of query on criteria.
308     * @param model the search model.
309     * @param values The submitted values
310     * @param language The query language.
311     * @param contextualParameters The contextual parameters
312     * @return The criteria {@link Query}.
313     */
314    protected List<Query> getCriteriaQueries(SearchUIModel model, Map<String, Object> values, String language, Map<String, Object> contextualParameters)
315    {
316        List<Query> queries = new ArrayList<>();
317        Map<String, SearchUICriterion> criteria = model.getCriteria(contextualParameters);
318        
319        for (String id : criteria.keySet())
320        {
321            SearchUICriterion criterion = criteria.get(id);
322            Object submitValue = values.get(id);
323            
324            // If the criterion is hidden, take the default value (fixed in the search model).
325            // Otherwise take the standard user value.
326            Object value = criterion.isHidden() ? criterion.getDefaultValue() : submitValue;
327            
328            if (value != null || criterion.getOperator() == Operator.EXISTS)
329            {
330                Query query = criterion.getQuery(value, values, language, contextualParameters);
331                if (query != null)
332                {
333                    queries.add(query);
334                }
335            }
336        }
337        
338        return queries;
339    }
340    
341    /**
342     * Get a complex Query from the advanced search values.
343     * @param model the search model.
344     * @param values The submitted values
345     * @param language The query language. 
346     * @param contextualParameters The contextual parameters
347     * @return The criteria {@link Query}.
348     */
349    protected Query getAdvancedCriteriaQuery(SearchUIModel model, Map<String, Object> values, String language, Map<String, Object> contextualParameters)
350    {
351        Query query = null;
352        
353        Map<String, ? extends SearchUICriterion> criteria = model.getAdvancedCriteria(contextualParameters);
354        String type = (String) values.get("type");
355        
356        if ("criterion".equals(type))
357        {
358            // Criterion node: return a simple Query object.
359            String id = (String) values.get("id");
360            Object value = values.get("value");
361            String op = (String) values.get("op");
362            
363            SearchUICriterion criterion = criteria.get(id);
364            
365            query = getAdvancedCriterionQuery(criterion, op, value, language, contextualParameters);
366        }
367        else if ("and".equalsIgnoreCase(type) || "or".equalsIgnoreCase(type))
368        {
369            // AND/OR node: recurse on each sub-query and return a AndQuery/OrQuery.
370            List<Query> queries = new ArrayList<>();
371            
372            @SuppressWarnings("unchecked")
373            List<Map<String, Object>> expressions = (List<Map<String, Object>>) values.get("expressions");
374            for (Map<String, Object> expression : expressions)
375            {
376                Query expQuery = getAdvancedCriteriaQuery(model, expression, language, contextualParameters);
377                if (expQuery != null)
378                {
379                    queries.add(expQuery);
380                }
381            }
382            
383            query = "and".equalsIgnoreCase(type) ? new AndQuery(queries) : new OrQuery(queries);
384        }
385        
386        return query;
387    }
388
389    /**
390     * Build the {@link Query} object corresponding to an advanced criterion value.
391     * @param criterion The search criterion.
392     * @param op The advanced operator (can be a custom one, such as "contain").
393     * @param value The user value.
394     * @param language The query language. 
395     * @param contextualParameters the search contextual parameters.
396     * @return The criterion {@link Query}.
397     */
398    protected Query getAdvancedCriterionQuery(SearchUICriterion criterion, String op, Object value, String language, Map<String, Object> contextualParameters)
399    {
400        Query query = null;
401        
402        // Special wildcard searches.
403        if ("contains".equals(op) || "not-contains".equals(op))
404        {
405            String wdValue = "*" + filterWildcardChars((String) value) + "*";
406            query = criterion.getQuery(wdValue, Operator.LIKE, Collections.emptyMap(), language, contextualParameters);
407            if ("not-contains".equals(op))
408            {
409                query = new NotQuery(query);
410            }
411        }
412        else if ("starts-with".equals(op) || "not-starts-with".equals(op))
413        {
414            String wdValue = filterWildcardChars((String) value) + "*";
415            query = criterion.getQuery(wdValue, Operator.LIKE, Collections.emptyMap(), language, contextualParameters);
416            if ("not-starts-with".equals(op))
417            {
418                query = new NotQuery(query);
419            }
420        }
421        else if ("ends-with".equals(op) || "not-ends-with".equals(op))
422        {
423            String wdValue = "*" + filterWildcardChars((String) value);
424            query = criterion.getQuery(wdValue, Operator.LIKE, Collections.emptyMap(), language, contextualParameters);
425            if ("not-ends-with".equals(op))
426            {
427                query = new NotQuery(query);
428            }
429        }
430        else if (StringUtils.isNotBlank(op))
431        {
432            Operator operator = Operator.fromName(op);
433            query = criterion.getQuery(value, operator, Collections.emptyMap(), language, contextualParameters);
434        }
435        
436        return query;
437    }
438    
439    /**
440     * Filter wildcard characters '*' and '?' from the input string by replacing them with a space.
441     * @param string The string to filter.
442     * @return The filtered string.
443     */
444    protected String filterWildcardChars(String string)
445    {
446        return StringUtils.replaceChars(string, "*?", "  ");
447    }
448    
449}