001/*
002 *  Copyright 2017 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.HashMap;
021import java.util.LinkedHashSet;
022import java.util.List;
023import java.util.Map;
024import java.util.Optional;
025import java.util.Set;
026import java.util.function.Predicate;
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.lang3.StringUtils;
035
036import org.ametys.cms.search.Sort.Order;
037import org.ametys.cms.search.model.SearchModel;
038import org.ametys.cms.search.model.SearchModelHelper;
039import org.ametys.cms.search.query.AndQuery;
040import org.ametys.cms.search.query.ContentTypeOrMixinTypeQuery;
041import org.ametys.cms.search.query.ContentTypeQuery;
042import org.ametys.cms.search.query.MixinTypeQuery;
043import org.ametys.cms.search.query.Query;
044import org.ametys.cms.search.query.Query.Operator;
045import org.ametys.cms.search.query.QuerySyntaxException;
046import org.ametys.cms.search.ui.model.SearchUIColumn;
047import org.ametys.core.util.JSONUtils;
048import org.ametys.runtime.model.ViewHelper;
049import org.ametys.runtime.model.ViewItem;
050import org.ametys.runtime.model.ViewItemAccessor;
051import org.ametys.runtime.model.ViewItemContainer;
052
053/**
054 * Helper to get query infos from JSON object
055 */
056public class GetQueryFromJSONHelper extends AbstractLogEnabled implements Component, Serviceable
057{
058    /** The Avalon role name */
059    public static final String ROLE = GetQueryFromJSONHelper.class.getName();
060    
061    private QueryBuilder _queryBuilder;
062    private JSONUtils _jsonUtils;
063    private SearchModelHelper _searchModelHelper;
064    
065    public void service(ServiceManager serviceManager) throws ServiceException
066    {
067        _queryBuilder = (QueryBuilder) serviceManager.lookup(QueryBuilder.ROLE);
068        _jsonUtils = (JSONUtils) serviceManager.lookup(JSONUtils.ROLE);
069        _searchModelHelper = (SearchModelHelper) serviceManager.lookup(SearchModelHelper.ROLE);
070    }
071
072    /**
073     * Retrieves the search UI model
074     * @param parameters The JS parameters
075     * @return the search UI model
076     */
077    public SearchModel getSearchModel(Map<String, Object> parameters)
078    {
079        return getSearchModel(parameters, "model");
080    }
081    
082    /**
083     * Retrieves the search UI model
084     * @param parameters The JS parameters
085     * @param modelKeyName key name of the model parameter
086     * @return the search UI model
087     */
088    @SuppressWarnings("unchecked")
089    public SearchModel getSearchModel(Map<String, Object> parameters, String modelKeyName)
090    {
091        String modelId = (String) parameters.get(modelKeyName);
092
093        List<String> restrictedContentTypeIds = (List<String>) parameters.get("restrictedContentTypes");
094        Map<String, Object> contextualParameters = Optional.ofNullable((Map<String, Object>) parameters.get("contextualParameters")).orElse(new HashMap<>());
095        
096        return _searchModelHelper.getSearchUIModel(modelId, restrictedContentTypeIds, contextualParameters);
097    }
098    
099    /**
100     * Retrieves a Query object from the SearchUIModel and the JSON parameters
101     * @param model Model
102     * @param parameters JSON parameters
103     * @param contentTypes List of content types to fill
104     * @return the created Query object
105     * @throws QuerySyntaxException If an error occurs during the query parsing
106     */
107    @SuppressWarnings("unchecked")
108    public Query getQueryFromModel(SearchModel model, Map<String, Object> parameters, List<String> contentTypes) throws QuerySyntaxException
109    {
110        Map<String, Object> contextualParameters = Optional.ofNullable((Map<String, Object>) parameters.get("contextualParameters")).orElseGet(HashMap::new);
111        
112        Map<String, Object> values = (Map<String, Object>) parameters.get("values");
113        String searchMode = StringUtils.defaultString((String) parameters.get("searchMode"), "simple");
114
115        Query query = _queryBuilder.build(model, searchMode, true , values, contextualParameters);
116        if (query instanceof AndQuery)
117        {
118            Set<Query> subqueries = new LinkedHashSet<>(((AndQuery) query).getQueries());
119            Predicate<Query> isCTypeOrMixinOrBothQuery = q -> ContentTypeQuery.class.isInstance(q) || MixinTypeQuery.class.isInstance(q) || ContentTypeOrMixinTypeQuery.class.isInstance(q);
120            List<Query> matchingQueries = subqueries.stream().distinct().filter(isCTypeOrMixinOrBothQuery).collect(Collectors.toList());
121            
122            if (matchingQueries.size() > 1)
123            {
124                // Mutliple candidates, try to be more selective and only keep a `ContentTypeQuery` one
125                Optional<Query> cTypeQuery = matchingQueries.stream().filter(ContentTypeQuery.class::isInstance).findFirst();
126                if (cTypeQuery.isPresent())
127                {
128                    matchingQueries = Collections.singletonList(cTypeQuery.get());
129                }
130                else
131                {
132                    // Mutliple candidates, try to be more selective and only keep a `ContentTypeOrMixinTypeQuery` one
133                    cTypeQuery = matchingQueries.stream().filter(ContentTypeOrMixinTypeQuery.class::isInstance).findFirst();
134                    if (cTypeQuery.isPresent())
135                    {
136                        matchingQueries = Collections.singletonList(cTypeQuery.get());
137                    }
138                }
139            }
140            
141            if (matchingQueries.size() == 1)
142            {
143                // be smart, fill the "contentTypes" field with this query
144                Query matchingQuery = matchingQueries.iterator().next();
145                boolean changeQuery = false;
146                if (matchingQuery instanceof ContentTypeQuery)
147                {
148                    ContentTypeQuery cTypeQuery = (ContentTypeQuery) matchingQuery;
149                    if (cTypeQuery.getOperator() == Operator.EQ)
150                    {
151                        contentTypes.addAll(cTypeQuery.getValue());
152                        changeQuery = true;
153                    }
154                }
155                else if (matchingQuery instanceof MixinTypeQuery)
156                {
157                    MixinTypeQuery mixinQuery = (MixinTypeQuery) matchingQuery;
158                    if (mixinQuery.getOperator() == Operator.EQ)
159                    {
160                        contentTypes.addAll(mixinQuery.getValue());
161                        changeQuery = true;
162                    }
163                }
164                else if (matchingQuery instanceof ContentTypeOrMixinTypeQuery)
165                {
166                    ContentTypeOrMixinTypeQuery cTypeOrMixinQuery = (ContentTypeOrMixinTypeQuery) matchingQuery;
167                    if (cTypeOrMixinQuery.getOperator() == Operator.EQ)
168                    {
169                        contentTypes.addAll(cTypeOrMixinQuery.getIds());
170                        changeQuery = true;
171                    }
172                }
173                
174                if (changeQuery)
175                {
176                    // Change the query by putting the same AndQuery but without the part about contentTypes which are in the contentTypes field
177                    subqueries.remove(matchingQuery);
178                    query = new AndQuery(subqueries);
179                }
180            }
181        }
182        
183        return query;
184    }
185    
186    /**
187     * Retrieves a list of column ids
188     * @param model search model containing result fields
189     * @param contextualParameters JSON object containing the contextual parameters
190     * @return a list of columns ids
191     */
192    public List<String> getColumnsFromSearchModel(SearchModel model, Map<String, Object> contextualParameters)
193    {
194        ViewItemContainer resultItems = model.getResultItems(contextualParameters != null ? contextualParameters : Collections.EMPTY_MAP);
195        return _getColumnIds(resultItems);
196    }
197    
198    private List<String> _getColumnIds(ViewItemAccessor viewItemAccessor)
199    {
200        List<String> columnIds = new ArrayList<>();
201        
202        for (ViewItem viewItem : viewItemAccessor.getViewItems())
203        {
204            if (viewItem instanceof SearchUIColumn searchUIColumn)
205            {
206                String columnId = ViewHelper.getModelViewItemPath(searchUIColumn);
207                columnIds.add(columnId);
208            }
209            else if (viewItem instanceof ViewItemAccessor itemAccessor)
210            {
211                columnIds.addAll(_getColumnIds(itemAccessor));
212            }
213        }
214        
215        return columnIds;
216    }
217    
218    /**
219     * Retrieves the sort criteria from a sort string.
220     * @param parameters The parameters (containing sort and optionally group keys)
221     * @return the sort criteria as a List of {@link Sort}.
222     */
223    public List<Sort> getSort(Map<String, Object> parameters)
224    {
225        String sortString = (String) parameters.get("sort");
226        String groupString = (String) parameters.get("group");
227        return getSort(sortString, groupString);
228    }
229    
230    /**
231     * Retrieves the sort criteria from a sort string.
232     * @param sortString The sort criteria as a JSON-encoded string.
233     * @param groupString The group criteria as a JSON-encoded string (for server-side grouping feature). Can be null.
234     * @return the sort criteria as a List of {@link Sort}.
235     */
236    public List<Sort> getSort(String sortString, String groupString)
237    {
238        List<Sort> sort = new ArrayList<>();
239        
240        List<Object> sortList = new ArrayList<>(_jsonUtils.convertJsonToList(sortString));
241        if (StringUtils.isNotEmpty(groupString))
242        {
243            // Grouping will be treated server side as a sort. It just needs to be before all the sorters
244            sortList.add(0, _jsonUtils.convertJsonToMap(groupString));
245        }
246        
247        for (Object object : sortList)
248        {
249            if (object instanceof Map)
250            {
251                Map map = (Map) object;
252                String fieldId = (String) map.get("property");
253                boolean ascending = "ASC".equals(map.get("direction"));
254                
255                sort.add(new Sort(fieldId, ascending ? Order.ASC : Order.DESC));
256            }
257        }
258        
259        return sort;
260    }    
261}
262