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