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.HashMap;
020import java.util.HashSet;
021import java.util.List;
022import java.util.Map;
023import java.util.Set;
024import java.util.function.BiFunction;
025
026import org.apache.avalon.framework.component.Component;
027import org.apache.avalon.framework.logger.AbstractLogEnabled;
028import org.apache.avalon.framework.service.ServiceException;
029import org.apache.avalon.framework.service.ServiceManager;
030import org.apache.avalon.framework.service.Serviceable;
031
032import org.ametys.cms.contenttype.ContentType;
033import org.ametys.cms.contenttype.ContentTypeExtensionPoint;
034import org.ametys.cms.search.advanced.AbstractTreeNode;
035import org.ametys.cms.search.advanced.AdvancedQueryBuilder;
036import org.ametys.cms.search.advanced.TreeMaker;
037import org.ametys.cms.search.advanced.TreeMaker.ClientSideCriterionWrapper;
038import org.ametys.cms.search.advanced.WrappedValue;
039import org.ametys.cms.search.advanced.utils.TreePrinter;
040import org.ametys.cms.search.model.ContentTypesAwareCriterionDefinition;
041import org.ametys.cms.search.model.LanguageAwareCriterionDefinition;
042import org.ametys.cms.search.model.SearchModel;
043import org.ametys.cms.search.model.SearchModelCriterionDefinition;
044import org.ametys.cms.search.model.SearchModelHelper;
045import org.ametys.cms.search.query.AndQuery;
046import org.ametys.cms.search.query.ContentLanguageQuery;
047import org.ametys.cms.search.query.ContentTypeQuery;
048import org.ametys.cms.search.query.Query;
049import org.ametys.cms.search.query.Query.Operator;
050import org.ametys.cms.search.ui.model.SearchModelCriterionViewItem;
051import org.ametys.cms.search.ui.model.SearchUIModel;
052import org.ametys.runtime.model.ModelViewItem;
053import org.ametys.runtime.model.ViewItem;
054import org.ametys.runtime.model.ViewItemAccessor;
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    /** Key of flag present in contextual parameters to indicate the current search is multilingual */
065    public static final String MULTILINGUAL_SEARCH = "multilingualSearch";
066
067    /** Key of flag present in contextual parameters to indicate the provided value was already escaped */
068    public static final String VALUE_IS_ESCAPED = "isEscapedValue";
069    
070    /** Key of flag present in contextual parameters to indicate the current search model */
071    public static final String SEARCH_MODEL = "searchModel";
072    
073    /** The content type extension point. */
074    protected ContentTypeExtensionPoint _cTypeEP;
075
076    /** The Advanced tree maker */
077    protected TreeMaker _advancedTreeMaker;
078    
079    /** The advanced query builder */
080    protected AdvancedQueryBuilder _advancedQueryBuilder;
081    
082    /** The search model helper */
083    protected SearchModelHelper _searchModelHelper;
084
085    @Override
086    public void service(ServiceManager serviceManager) throws ServiceException
087    {
088        _cTypeEP = (ContentTypeExtensionPoint) serviceManager.lookup(ContentTypeExtensionPoint.ROLE);
089        _advancedTreeMaker = (TreeMaker) serviceManager.lookup(TreeMaker.ROLE);
090        _advancedQueryBuilder = (AdvancedQueryBuilder) serviceManager.lookup(AdvancedQueryBuilder.ROLE);
091        _searchModelHelper = (SearchModelHelper) serviceManager.lookup(SearchModelHelper.ROLE);
092    }
093
094    /**
095     * Build the {@link Query} object.
096     * @param model the search model.
097     * @param searchMode the search mode.
098     * @param values the user search values.
099     * @param contextualParameters the search contextual parameters.
100     * @return a {@link Query} object representing the search.
101     */
102    public Query build(SearchModel model, String searchMode, Map<String, Object> values, Map<String, Object> contextualParameters)
103    {
104        Map<String, Object> copiedValues = new HashMap<>(values);
105        List<Query> queries = new ArrayList<>();
106
107        Map<String, Object> modifiableContextualParameters = new HashMap<>(contextualParameters);
108        modifiableContextualParameters.put(SEARCH_MODEL, model);
109        
110        Set<String> modelContentTypes = model.getContentTypes(contextualParameters);
111        if (_isMultilingualSearch(modelContentTypes))
112        {
113            modifiableContextualParameters.put(MULTILINGUAL_SEARCH, true);
114        }
115
116        Set<String> modelExcludedCTypes = model.getExcludedContentTypes(modifiableContextualParameters);
117        if (!modelExcludedCTypes.isEmpty())
118        {
119            // query is on `allContentTypes` field, so do not be redundant and only keep the top supertypes for a readable query
120            queries.add(new ContentTypeQuery(Operator.NE, _getOnlySuperTypes(modelExcludedCTypes)));
121        }
122
123        String language = _searchModelHelper.getCriteriaLanguage(model, searchMode, copiedValues, modifiableContextualParameters);
124        CriteriaQueries criteriaQueries = "advanced".equals(searchMode) && model instanceof SearchUIModel uiModel
125                ? getAdvancedCriteriaQuery(uiModel, copiedValues, language, modifiableContextualParameters)
126                : getCriteriaQueries(model, copiedValues, language, modifiableContextualParameters);
127        queries.addAll(criteriaQueries.queries());
128        
129        if ("advanced".equals(searchMode) && !criteriaQueries.isLanguageFound())
130        {
131            Query contentLanguageQuery = new ContentLanguageQuery(language);
132            queries.add(contentLanguageQuery);
133        }
134        
135        if (!criteriaQueries.areContentTypesFound())
136        {
137            Query modelContentTypesQuery = _searchModelHelper.createContentTypeOrMixinQuery(modelContentTypes);
138            queries.add(modelContentTypesQuery);
139        }
140
141        return new AndQuery(queries);
142    }
143    
144    private boolean _isMultilingualSearch(Set<String> cTypeIds)
145    {
146        if (cTypeIds == null)
147        {
148            return false;
149        }
150        
151        for (String cTypeId : cTypeIds)
152        {
153            if (!_cTypeEP.getExtension(cTypeId).isMultilingual())
154            {
155                return false;
156            }
157        }
158        
159        // All concerned content types are multilingual
160        return true;
161    }
162
163    private Set<String> _getOnlySuperTypes(Set<String> cTypes)
164    {
165        Set<String> result = new HashSet<>();
166
167        for (String cTypeId : cTypes)
168        {
169            ContentType cType = _cTypeEP.getExtension(cTypeId);
170            if (!_containsAny(cTypes, cType.getSupertypeIds()))
171            {
172                result.add(cTypeId);
173            }
174        }
175
176        return result;
177    }
178
179    // Returns true if at least one of the supertypeIds is in cTypes
180    private boolean _containsAny(Set<String> cTypes, String[] supertypeIds)
181    {
182        for (String supertypeId : supertypeIds)
183        {
184            if (cTypes.contains(supertypeId))
185            {
186                return true;
187            }
188        }
189        return false;
190    }
191
192    /**
193     * Get the list of query on given model's criteria.
194     * @param model the model
195     * @param values The submitted values
196     * @param language The query language.
197     * @param contextualParameters The contextual parameters
198     * @return The criteria {@link Query}.
199     */
200    protected CriteriaQueries getCriteriaQueries(SearchModel model, Map<String, Object> values, String language, Map<String, Object> contextualParameters)
201    {
202        return getCriteriaQueries(model.getCriteria(contextualParameters), values, language, contextualParameters);
203    }
204
205    /**
206     * Get the list of query on criteria.
207     * @param criteria the list of criteria
208     * @param values The submitted values
209     * @param language The query language.
210     * @param contextualParameters The contextual parameters
211     * @return The criteria {@link Query}.
212     */
213    @SuppressWarnings("unchecked")
214    protected CriteriaQueries getCriteriaQueries(ViewItemAccessor criteria, Map<String, Object> values, String language, Map<String, Object> contextualParameters)
215    {
216        List<Query> queries = new ArrayList<>();
217        boolean isLanguageFound = false;
218        boolean areContentTypesFound = false;
219        
220        for (ViewItem viewItem : criteria.getViewItems())
221        {
222            if (viewItem instanceof ModelViewItem modelViewItem
223                    && modelViewItem.getDefinition() instanceof SearchModelCriterionDefinition criterion)
224            {
225                Object submitValue = values.get(criterion.getName());
226                
227                // If the criterion is hidden, take the default value (fixed in the search model).
228                // Otherwise take the standard user value.
229                Object untypedValue = modelViewItem instanceof SearchModelCriterionViewItem criterionViewItem && criterionViewItem.isHidden()
230                        ? criterion.getDefaultValue()
231                        : submitValue;
232
233                Object value = criterion.convertQueryValue(untypedValue, contextualParameters);
234                Query query = criterion.getQuery(value, values, language, contextualParameters);
235                if (query != null)
236                {
237                    queries.add(query);
238                }
239                
240                isLanguageFound = criterion instanceof LanguageAwareCriterionDefinition || isLanguageFound;
241                areContentTypesFound = criterion instanceof ContentTypesAwareCriterionDefinition || areContentTypesFound;
242            }
243            
244            if (viewItem instanceof ViewItemAccessor itemAccessor)
245            {
246                CriteriaQueries criteriaQueries = getCriteriaQueries(itemAccessor, values, language, contextualParameters);
247                queries.addAll(criteriaQueries.queries());
248                isLanguageFound = criteriaQueries.isLanguageFound() || isLanguageFound;
249                areContentTypesFound = criteriaQueries.areContentTypesFound() || areContentTypesFound;
250            }
251        }
252
253        return new CriteriaQueries(queries, isLanguageFound, areContentTypesFound);
254    }
255
256    /**
257     * Get a complex Query from the advanced search values.
258     * @param model the model containing criterion definitions
259     * @param values The submitted values
260     * @param language The query language.
261     * @param contextualParameters The contextual parameters
262     * @return The criteria {@link Query}.
263     */
264    protected CriteriaQueries getAdvancedCriteriaQuery(SearchUIModel model, Map<String, Object> values, String language, Map<String, Object> contextualParameters)
265    {
266        AbstractTreeNode<ValuedCriterion<Object>> tree = _createTreeNode(model, values, contextualParameters);
267        if (tree == null)
268        {
269            return null;
270        }
271        
272        if (getLogger().isDebugEnabled())
273        {
274            getLogger().debug("\n" + TreePrinter.print(tree, c -> "{" + c._criterionDefinition.getName() + ": Operator=" + c._op + ", Value=" + c._value + "}"));
275        }
276        Query query = _advancedQueryBuilder.build(tree, valuedCrit -> valuedCrit.toQuery(language, contextualParameters));
277        
278        boolean isLanguageFound = AbstractTreeNode.walk(tree, 
279                // leaf => check if it is a criterion managing language
280                leaf -> leaf.getValue().isLanguageCriterion(), 
281                // check if one of the leaf managed language
282                (booleans, operator) -> booleans.findAny().orElse(false));
283        
284        boolean areContentTypesFound = AbstractTreeNode.walk(tree, 
285                // leaf => check if it is a criterion managing content types
286                leaf -> leaf.getValue().isContentTypesCriterion(), 
287                // check if one of the leaf managed content types
288                (booleans, operator) -> booleans.findAny().orElse(false));
289        
290        return new CriteriaQueries(query != null ? List.of(query) : List.of(), isLanguageFound, areContentTypesFound);
291    }
292    
293    @SuppressWarnings("unchecked")
294    private <T> AbstractTreeNode<ValuedCriterion<T>> _createTreeNode(SearchUIModel model, Map<String, Object> values, Map<String, Object> contextualParameters)
295    {
296        return _advancedTreeMaker.create(values, clientSideCrit -> new ValuedCriterion<>((SearchModelCriterionDefinition<T>) model.getAdvancedCriterion(clientSideCrit.getId(), contextualParameters).getDefinition(), clientSideCrit, _advancedTreeMaker));
297    }
298    
299    private static final class ValuedCriterion<T>
300    {
301        SearchModelCriterionDefinition<T> _criterionDefinition;
302        String _op;
303        Object _value;
304        private TreeMaker _advancedTreeMaker;
305
306        ValuedCriterion(SearchModelCriterionDefinition<T> criterionDefinition, ClientSideCriterionWrapper clientSideCriterion, TreeMaker advancedTreeMaker)
307        {
308            _criterionDefinition = criterionDefinition;
309            _op = clientSideCriterion.getStringOperator();
310            _value = clientSideCriterion.getValue();
311            _advancedTreeMaker = advancedTreeMaker;
312        }
313        
314        Query toQuery(String language, Map<String, Object> contextualParameters)
315        {
316            BiFunction<WrappedValue, Operator, Query> toQuery = (transformedVal, realOperator) -> _criterionDefinition.getQuery(transformedVal.getValue(), realOperator, language, contextualParameters);
317            Object transformedValue = _criterionDefinition.convertQueryValue(_value, contextualParameters);
318            WrappedValue wrappedValue = new WrappedValue(transformedValue);
319            return _advancedTreeMaker.toQuery(wrappedValue, _op, toQuery, language, contextualParameters);
320        }
321        
322        boolean isLanguageCriterion()
323        {
324            return _criterionDefinition instanceof LanguageAwareCriterionDefinition;
325        }
326        
327        boolean isContentTypesCriterion()
328        {
329            return _criterionDefinition instanceof ContentTypesAwareCriterionDefinition;
330        }
331    }
332
333    private record CriteriaQueries(List<Query> queries, boolean isLanguageFound, boolean areContentTypesFound) { /* Empty */ }
334}