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