001/*
002 *  Copyright 2016 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.solr;
017
018import java.net.URLDecoder;
019import java.util.ArrayList;
020import java.util.Collection;
021import java.util.HashMap;
022import java.util.LinkedHashMap;
023import java.util.List;
024import java.util.Map;
025
026import org.apache.avalon.framework.activity.Initializable;
027import org.apache.avalon.framework.component.Component;
028import org.apache.avalon.framework.service.ServiceException;
029import org.apache.avalon.framework.service.ServiceManager;
030import org.apache.avalon.framework.service.Serviceable;
031import org.apache.commons.collections.CollectionUtils;
032import org.apache.commons.lang3.StringUtils;
033import org.apache.solr.client.solrj.SolrClient;
034import org.apache.solr.client.solrj.SolrQuery;
035import org.apache.solr.client.solrj.SolrQuery.ORDER;
036import org.apache.solr.client.solrj.SolrRequest.METHOD;
037import org.apache.solr.client.solrj.response.FacetField;
038import org.apache.solr.client.solrj.response.FacetField.Count;
039import org.apache.solr.client.solrj.response.QueryResponse;
040import org.slf4j.Logger;
041
042import org.ametys.cms.search.SearchField;
043import org.ametys.cms.search.SearchResults;
044import org.ametys.cms.search.Sort;
045import org.ametys.cms.search.Sort.Order;
046import org.ametys.cms.search.filter.AccessSearchFilter;
047import org.ametys.cms.search.filter.AccessSearchFilterExtensionPoint;
048import org.ametys.cms.search.query.Query;
049import org.ametys.cms.search.query.QuerySyntaxException;
050import org.ametys.core.user.CurrentUserProvider;
051import org.ametys.plugins.repository.AmetysObject;
052import org.ametys.plugins.repository.AmetysObjectIterable;
053import org.ametys.plugins.repository.AmetysObjectResolver;
054import org.ametys.runtime.plugin.component.AbstractLogEnabled;
055
056/**
057 * Component searching objects corresponding to a {@link Query}.
058 */
059public class SearcherFactory extends AbstractLogEnabled implements Component, Serviceable, Initializable
060{
061    
062    /** The component role. */
063    public static final String ROLE = SearcherFactory.class.getName();
064    
065    /** The {@link AmetysObjectResolver} */
066    protected AmetysObjectResolver _resolver;
067    
068    /** The solr client provider */
069    protected SolrClientProvider _solrClientProvider;
070    
071    /** The current user provider. */
072    protected CurrentUserProvider _currentUserProvider;
073    
074    /** The search filter extension point. */
075    protected AccessSearchFilterExtensionPoint _searchFilterEP;
076    
077    /** The solr client */
078    protected SolrClient _solrClient;
079    
080    @Override
081    public void service(ServiceManager serviceManager) throws ServiceException
082    {
083        _resolver = (AmetysObjectResolver) serviceManager.lookup(AmetysObjectResolver.ROLE);
084        _solrClientProvider = (SolrClientProvider) serviceManager.lookup(SolrClientProvider.ROLE);
085        _currentUserProvider = (CurrentUserProvider) serviceManager.lookup(CurrentUserProvider.ROLE);
086        _searchFilterEP = (AccessSearchFilterExtensionPoint) serviceManager.lookup(AccessSearchFilterExtensionPoint.ROLE);
087    }
088    
089    @Override
090    public void initialize() throws Exception
091    {
092        _solrClient = _solrClientProvider.getReadClient();
093    }
094    
095    /**
096     * Create a Searcher.
097     * @return a Searcher object.
098     */
099    public Searcher create()
100    {
101        return new Searcher(getLogger());
102    }
103    
104    /**
105     * Class searching objects corresponding to a query, with optional sort, facets, and so on.
106     */
107    public class Searcher
108    {
109        private Logger _logger;
110        
111        private String _queryString;
112        private Query _query;
113        private List<String> _filterQueryStrings;
114        private List<Query> _filterQueries;
115        private List<Sort> _sortClauses;
116        private List<SearchField> _facets;
117        private Map<String, List<String>> _facetValues;
118        private int _start;
119        private int _maxResults;
120        private Map<String, Object> _searchContext;
121        private boolean _checkRights;
122        
123        /**
124         * Build a Searcher with default values.
125         * @param logger The logger.
126         */
127        protected Searcher(Logger logger)
128        {
129            _logger = logger;
130            
131            _filterQueryStrings = new ArrayList<>();
132            _filterQueries = new ArrayList<>();
133            _sortClauses = new ArrayList<>();
134            _facets = new ArrayList<>();
135            _facetValues = new HashMap<>();
136            _start = 0;
137            _maxResults = Integer.MAX_VALUE;
138            _searchContext = new HashMap<>();
139            _checkRights = true;
140        }
141        
142        /**
143         * Set the query (as a String).
144         * @param query the query (as a String).
145         * @return The Searcher object itself.
146         */
147        public Searcher withQueryString(String query)
148        {
149            if (this._query != null)
150            {
151                throw new IllegalArgumentException("Query and query string can't be used at the same time.");
152            }
153            this._queryString = query;
154            return this;
155        }
156        
157        /**
158         * Set the query (as a {@link Query} object).
159         * @param query the query (as a {@link Query} object).
160         * @return The Searcher object itself.
161         */
162        public Searcher withQuery(Query query)
163        {
164            if (this._queryString != null)
165            {
166                throw new IllegalArgumentException("Query and query string can't be used at the same time.");
167            }
168            this._query = query;
169            return this;
170        }
171        
172        /**
173         * Set the filter queries (as Strings).
174         * @param queries the filter queries (as Strings).
175         * @return The Searcher object itself. The Searcher object itself.
176         */
177        public Searcher withFilterQueryStrings(String... queries)
178        {
179            _filterQueryStrings = new ArrayList<>(queries.length);
180            CollectionUtils.addAll(_filterQueryStrings, queries);
181            return this;
182        }
183        
184        /**
185         * Set the filter queries (as Strings).
186         * @param queries the filter queries (as Strings).
187         * @return The Searcher object itself. The Searcher object itself.
188         */
189        public Searcher withFilterQueryStrings(Collection<String> queries)
190        {
191            _filterQueryStrings = new ArrayList<>(queries);
192            return this;
193        }
194        
195        /**
196         * Add a filter query to the existing ones (as a String).
197         * @param query the filter query to add (as a String).
198         * @return The Searcher object itself. The Searcher object itself.
199         */
200        public Searcher addFilterQueryString(String query)
201        {
202            _filterQueryStrings.add(query);
203            return this;
204        }
205        
206        /**
207         * Set the filter queries (as {@link Query} objects).
208         * @param queries the filter queries (as {@link Query} objects).
209         * @return The Searcher object itself. The Searcher object itself.
210         */
211        public Searcher withFilterQueries(Query... queries)
212        {
213            _filterQueries = new ArrayList<>(queries.length);
214            CollectionUtils.addAll(_filterQueries, queries);
215            return this;
216        }
217        
218        /**
219         * Set the filter queries (as {@link Query} objects).
220         * @param queries the filter queries (as {@link Query} objects).
221         * @return The Searcher object itself. The Searcher object itself.
222         */
223        public Searcher withFilterQueries(Collection<Query> queries)
224        {
225            _filterQueries = new ArrayList<>(queries);
226            return this;
227        }
228        
229        /**
230         * Add a filter query to the existing ones (as a {@link Query} object).
231         * @param query the filter query to add (as a {@link Query} object).
232         * @return The Searcher object itself. The Searcher object itself.
233         */
234        public Searcher addFilterQuery(Query query)
235        {
236            _filterQueries.add(query);
237            return this;
238        }
239        
240        /**
241         * Set the sort clauses.
242         * @param sortClauses the sort clauses.
243         * @return The Searcher object itself.
244         */
245        public Searcher withSort(Sort... sortClauses)
246        {
247            _sortClauses = new ArrayList<>(sortClauses.length);
248            CollectionUtils.addAll(_sortClauses, sortClauses);
249            return this;
250        }
251        
252        /**
253         * Set the sort clauses.
254         * @param sortClauses the sort clauses.
255         * @return The Searcher object itself.
256         */
257        public Searcher withSort(List<Sort> sortClauses)
258        {
259            _sortClauses = new ArrayList<>(sortClauses);
260            return this;
261        }
262        
263        /**
264         * Add a sort clause to the existing ones.
265         * @param sortClause The sort clause to add.
266         * @return The Searcher object itself.
267         */
268        public Searcher addSort(Sort sortClause)
269        {
270            _sortClauses.add(sortClause);
271            return this;
272        }
273        
274        /**
275         * Set the faceted fields.
276         * @param facets the faceted fields.
277         * @return The Searcher object itself.
278         */
279        public Searcher withFacets(SearchField... facets)
280        {
281            _facets = new ArrayList<>(facets.length);
282            CollectionUtils.addAll(_facets, facets);
283            return this;
284        }
285        
286        /**
287         * Set the faceted fields.
288         * @param facets the faceted fields.
289         * @return The Searcher object itself.
290         */
291        public Searcher withFacets(Collection<SearchField> facets)
292        {
293            _facets = new ArrayList<>(facets);
294            return this;
295        }
296        
297        /**
298         * Add a faceted field.
299         * @param facet The faceted field to add.
300         * @return The Searcher object itself.
301         */
302        public Searcher addFacet(SearchField facet)
303        {
304            _facets.add(facet);
305            return this;
306        }
307        
308        /**
309         * Set the facet values.
310         * @param facetValues The facet values.
311         * @return The Searcher object itself.
312         */
313        public Searcher withFacetValues(Map<String, List<String>> facetValues)
314        {
315            _facetValues = new HashMap<>(facetValues);
316            return this;
317        }
318        
319        /**
320         * Set the search offset and limit.
321         * @param start The start index (offset).
322         * @param maxResults The maximum number of results.
323         * @return The Searcher object itself.
324         */
325        public Searcher withLimits(int start, int maxResults)
326        {
327            this._start = start;
328            this._maxResults = maxResults;
329            return this;
330        }
331        
332        /**
333         * Set the search context.
334         * @param searchContext The search context.
335         * @return The Searcher object itself.
336         */
337        public Searcher withContext(Map<String, Object> searchContext)
338        {
339            _searchContext = new HashMap<>(searchContext);
340            return this;
341        }
342        
343        /**
344         * Add a value to the search context.
345         * @param key The context key.
346         * @param value The value.
347         * @return The Searcher object itself.
348         */
349        public Searcher addContextElement(String key, Object value)
350        {
351            _searchContext.put(key, value);
352            return this;
353        }
354        
355        /**
356         * Whether to check rights when searching, false otherwise.
357         * @param checkRights <code>true</code> to check rights, <code>false</code> otherwise.
358         * @return The Searcher object itself.
359         */
360        public Searcher setCheckRights(boolean checkRights)
361        {
362            _checkRights = checkRights;
363            return this;
364        }
365        
366        /**
367         * Execute the search with the current parameters.
368         * @param <A> The type of search results
369         * @return An iterable on the result ametys objects.
370         * @throws Exception If an error occurs.
371         */
372        public <A extends AmetysObject> AmetysObjectIterable<A> search() throws Exception
373        {
374            SearchResults<A> searchResults = searchWithFacets();
375            return searchResults.getObjects();
376        }
377        
378        /**
379         * Execute the search with the current parameters.
380         * @param <A> The type of search results
381         * @return An iterable on the search result objects.
382         * @throws Exception If an error occurs.
383         */
384        public <A extends AmetysObject> SearchResults<A> searchWithFacets() throws Exception
385        {
386            QueryResponse response = _querySolrServer();
387            Map<String, Map<String, Integer>> facetResults = getFacetResults(response, _facets);
388            
389            return new SolrSearchResults<>(response, _resolver, facetResults);
390        }
391        
392        private QueryResponse _querySolrServer() throws Exception
393        {
394            String query = getQuery();
395            List<String> filterQueries = getFilterQueries();
396            
397            SolrQuery solrQuery = getSolrQuery(query, filterQueries, _start, _maxResults, _searchContext, _checkRights);
398            
399            // Set the sort specification and facets in the solr query object.
400            setSort(solrQuery, _sortClauses);
401            setFacets(solrQuery, _facets, _facetValues);
402            
403            if (_logger.isInfoEnabled())
404            {
405                _logger.info("Solr query: " + URLDecoder.decode(solrQuery.toString(), "UTF-8"));
406            }
407            
408            // Use POST to avoid problems with large requests.
409            return _solrClient.query(_solrClientProvider.getCollectionName(), solrQuery, METHOD.POST);
410        }
411        
412        /**
413         * Get the query string from the parameters.
414         * @return The query string.
415         * @throws QuerySyntaxException If the query is invalid.
416         */
417        protected String getQuery() throws QuerySyntaxException
418        {
419            String query = "*:*";
420            if (_queryString != null)
421            {
422                query = _queryString;
423            }
424            else if (_query != null)
425            {
426                query = _query.build();
427            }
428            return query;
429        }
430        
431        /**
432         * Get the filter queries from the parameters.
433         * @return The list of filter queries.
434         * @throws QuerySyntaxException If one of the queries is invalid.
435         */
436        protected List<String> getFilterQueries() throws QuerySyntaxException
437        {
438            List<String> filterQueries = new ArrayList<>();
439            if (!_filterQueryStrings.isEmpty())
440            {
441                filterQueries = _filterQueryStrings;
442            }
443            
444            if (!_filterQueries.isEmpty())
445            {
446                for (Query fq : _filterQueries)
447                {
448                    filterQueries.add(fq.build());
449                }
450            }
451            
452            return filterQueries;
453        }
454        
455        /**
456         * Get the solr query object.
457         * @param query The solr query string.
458         * @param filterQueries The filter queries (as Strings).
459         * @param start The start index.
460         * @param maxResults The maximum number of results.
461         * @param searchContext The search context.
462         * @param checkRights Whether to check rights when searching or not.
463         * @return The solr query object.
464         * @throws Exception If an error occurs.
465         */
466        protected SolrQuery getSolrQuery(String query, Collection<String> filterQueries, int start, int maxResults, Map<String, Object> searchContext, boolean checkRights) throws Exception
467        {
468            SolrQuery solrQuery = new SolrQuery();
469            
470            String queryString = StringUtils.isNotBlank(query) ? query : "*:*";
471            
472            // Set the query string, pagination spec and fields to be returned.
473            solrQuery.setQuery(queryString);
474            solrQuery.setStart(start);
475            solrQuery.setRows(maxResults);
476            // Return only ID + score fields.
477            solrQuery.setFields("id", "score");
478            
479            // Add filter queries.
480            for (String fq : filterQueries)
481            {
482                solrQuery.addFilterQuery(fq);
483            }
484            
485            if (checkRights)
486            {
487                for (String filterId : _searchFilterEP.getExtensionsIds())
488                {
489                    AccessSearchFilter filter = _searchFilterEP.getExtension(filterId);
490                    for (Query fQuery : filter.getFilterQueries(searchContext))
491                    {
492                        String fq = fQuery.build();
493                        if (StringUtils.isNotBlank(fq))
494                        {
495                            solrQuery.addFilterQuery(fq);
496                        }
497                    }
498                }
499            }
500            
501            return solrQuery;
502        }
503        
504        /**
505         * Set the sort definition in the solr query object.
506         * @param solrQuery The solr query object.
507         * @param sortCriteria The sort criteria.
508         */
509        protected void setSort(SolrQuery solrQuery, List<Sort> sortCriteria)
510        {
511            for (Sort sortCriterion : sortCriteria)
512            {
513                solrQuery.addSort(sortCriterion.getField(), sortCriterion.getOrder() == Order.ASC ? ORDER.asc : ORDER.desc);
514            }
515        }
516        
517        /**
518         * Set the facet definition in the solr query object and return a mapping from solr field name to criterion ID.
519         * @param solrQuery the solr query object to fill.
520         * @param facets The facet definitions to use.
521         * @param facetValues the facet values.
522         */
523        protected void setFacets(SolrQuery solrQuery, Collection<SearchField> facets, Map<String, List<String>> facetValues)
524        {
525            if (facets.size() > 0)
526            {
527                // unlimited facet values
528                solrQuery.setFacetLimit(-1);
529            }
530            
531            for (SearchField facetField : facets)
532            {
533                String fieldName = facetField.getName();
534                String solrFieldName = facetField.getFacetField();
535                
536                if (StringUtils.isNotBlank(fieldName))
537                {
538                    String facetFieldDef = "{!ex=" + fieldName + " key=" + fieldName + "}" + solrFieldName;
539                    
540                    List<String> fieldFacetValues = facetValues.get(fieldName);
541                    if (fieldFacetValues != null && !fieldFacetValues.isEmpty())
542                    {
543                        StringBuilder fq = new StringBuilder();
544                        fq.append("{!tag=").append(fieldName).append("}(");
545                        
546                        int i = 0;
547                        for (String facetValue : fieldFacetValues)
548                        {
549                            if (i > 0)
550                            {
551                                fq.append(" OR ");
552                            }
553                            // TODO escape query chars?
554                            fq.append(solrFieldName).append(":\"").append(facetValue).append('"');
555                            i++;
556                        }
557                        fq.append(')');
558                        solrQuery.addFilterQuery(fq.toString());
559                    }
560                    
561                    solrQuery.addFacetField(facetFieldDef);
562                }
563            }
564        }
565        
566        /**
567         * Retrieve the facet results from the solr response.
568         * @param response the solr response.
569         * @param facets The facet fields to return.
570         * @return the facet results.
571         */
572        protected Map<String, Map<String, Integer>> getFacetResults(QueryResponse response, Collection<SearchField> facets)
573        {
574            Map<String, Map<String, Integer>> facetResults = new LinkedHashMap<>();
575            
576            for (SearchField facetField : facets)
577            {
578                String fieldName = facetField.getName();
579                FacetField solrFacetField = response.getFacetField(fieldName);
580                
581                List<Count> values = solrFacetField.getValues();
582                
583                Map<String, Integer> solrFacetValues = new HashMap<>();
584                facetResults.put(fieldName, solrFacetValues);
585                
586                for (Count count : values)
587                {
588                    solrFacetValues.put(count.getName(), (int) count.getCount());
589                }
590            }
591            
592            return facetResults;
593        }
594    }
595    
596}