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