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.io.ByteArrayOutputStream;
019import java.io.IOException;
020import java.io.OutputStream;
021import java.nio.charset.StandardCharsets;
022import java.util.ArrayList;
023import java.util.Collection;
024import java.util.HashMap;
025import java.util.LinkedHashMap;
026import java.util.List;
027import java.util.Map;
028import java.util.Set;
029import java.util.stream.Collectors;
030
031import org.apache.avalon.framework.activity.Initializable;
032import org.apache.avalon.framework.component.Component;
033import org.apache.avalon.framework.service.ServiceException;
034import org.apache.avalon.framework.service.ServiceManager;
035import org.apache.avalon.framework.service.Serviceable;
036import org.apache.commons.collections.CollectionUtils;
037import org.apache.commons.lang3.StringUtils;
038import org.apache.solr.client.solrj.SolrClient;
039import org.apache.solr.client.solrj.request.RequestWriter.ContentWriter;
040import org.apache.solr.client.solrj.request.json.DomainMap;
041import org.apache.solr.client.solrj.request.json.JsonQueryRequest;
042import org.apache.solr.client.solrj.request.json.TermsFacetMap;
043import org.apache.solr.client.solrj.response.FacetField;
044import org.apache.solr.client.solrj.response.FacetField.Count;
045import org.apache.solr.client.solrj.response.QueryResponse;
046import org.apache.solr.client.solrj.response.json.BucketBasedJsonFacet;
047import org.apache.solr.client.solrj.response.json.BucketJsonFacet;
048import org.apache.solr.common.params.CommonParams;
049import org.slf4j.Logger;
050
051import org.ametys.cms.search.SearchField;
052import org.ametys.cms.search.SearchResults;
053import org.ametys.cms.search.Sort;
054import org.ametys.cms.search.Sort.Order;
055import org.ametys.cms.search.query.JoinQuery;
056import org.ametys.cms.search.query.MatchAllQuery;
057import org.ametys.cms.search.query.OrQuery;
058import org.ametys.cms.search.query.Query;
059import org.ametys.cms.search.query.QuerySyntaxException;
060import org.ametys.core.group.GroupIdentity;
061import org.ametys.core.group.GroupManager;
062import org.ametys.core.right.AllowedUsers;
063import org.ametys.core.user.CurrentUserProvider;
064import org.ametys.core.user.UserIdentity;
065import org.ametys.plugins.repository.AmetysObject;
066import org.ametys.plugins.repository.AmetysObjectIterable;
067import org.ametys.plugins.repository.AmetysObjectResolver;
068import org.ametys.runtime.plugin.component.AbstractLogEnabled;
069
070/**
071 * Component searching objects corresponding to a {@link Query}.
072 */
073public class SearcherFactory extends AbstractLogEnabled implements Component, Serviceable, Initializable
074{
075    
076    /** The component role. */
077    public static final String ROLE = SearcherFactory.class.getName();
078    
079    /** The {@link AmetysObjectResolver} */
080    protected AmetysObjectResolver _resolver;
081    
082    /** The solr client provider */
083    protected SolrClientProvider _solrClientProvider;
084    
085    /** The current user provider. */
086    protected CurrentUserProvider _currentUserProvider;
087    
088    /** The group manager */
089    protected GroupManager _groupManager;
090    
091    /** The solr client */
092    protected SolrClient _solrClient;
093    
094    @Override
095    public void service(ServiceManager serviceManager) throws ServiceException
096    {
097        _resolver = (AmetysObjectResolver) serviceManager.lookup(AmetysObjectResolver.ROLE);
098        _solrClientProvider = (SolrClientProvider) serviceManager.lookup(SolrClientProvider.ROLE);
099        _currentUserProvider = (CurrentUserProvider) serviceManager.lookup(CurrentUserProvider.ROLE);
100        _groupManager = (GroupManager) serviceManager.lookup(GroupManager.ROLE);
101    }
102    
103    @Override
104    public void initialize() throws Exception
105    {
106        _solrClient = _solrClientProvider.getReadClient();
107    }
108    
109    /**
110     * Create a Searcher.
111     * @return a Searcher object.
112     */
113    public Searcher create()
114    {
115        return new Searcher(getLogger());
116    }
117    
118    /**
119     * Class searching objects corresponding to a query, with optional sort, facets, and so on.
120     */
121    public class Searcher
122    {
123        private Logger _logger;
124        
125        private String _queryString;
126        private Query _query;
127        private List<String> _filterQueryStrings;
128        private List<Query> _filterQueries;
129        private List<Sort> _sortClauses;
130        private List<SearchField> _facets;
131        private Map<String, List<String>> _facetValues;
132        private int _start;
133        private int _maxResults;
134        private Map<String, Object> _searchContext;
135        private boolean _checkRights;
136        private AllowedUsers _checkRightsComparingTo;
137        private boolean _debug;
138        
139        /**
140         * Build a Searcher with default values.
141         * @param logger The logger.
142         */
143        protected Searcher(Logger logger)
144        {
145            _logger = logger;
146            
147            _filterQueryStrings = new ArrayList<>();
148            _filterQueries = new ArrayList<>();
149            _sortClauses = new ArrayList<>();
150            _facets = new ArrayList<>();
151            _facetValues = new HashMap<>();
152            _start = 0;
153            _maxResults = Integer.MAX_VALUE;
154            _searchContext = new HashMap<>();
155            _checkRights = true;
156        }
157        
158        /**
159         * Set the query (as a String).
160         * @param query the query (as a String).
161         * @return The Searcher object itself.
162         */
163        public Searcher withQueryString(String query)
164        {
165            if (this._query != null)
166            {
167                throw new IllegalArgumentException("Query and query string can't be used at the same time.");
168            }
169            this._queryString = query;
170            return this;
171        }
172        
173        /**
174         * Set the query (as a {@link Query} object).
175         * @param query the query (as a {@link Query} object).
176         * @return The Searcher object itself.
177         */
178        public Searcher withQuery(Query query)
179        {
180            if (this._queryString != null)
181            {
182                throw new IllegalArgumentException("Query and query string can't be used at the same time.");
183            }
184            this._query = query;
185            return this;
186        }
187        
188        /**
189         * Set the filter queries (as Strings).
190         * @param queries the filter queries (as Strings).
191         * @return The Searcher object itself. The Searcher object itself.
192         */
193        public Searcher withFilterQueryStrings(String... queries)
194        {
195            _filterQueryStrings = new ArrayList<>(queries.length);
196            CollectionUtils.addAll(_filterQueryStrings, queries);
197            return this;
198        }
199        
200        /**
201         * Set the filter queries (as Strings).
202         * @param queries the filter queries (as Strings).
203         * @return The Searcher object itself. The Searcher object itself.
204         */
205        public Searcher withFilterQueryStrings(Collection<String> queries)
206        {
207            _filterQueryStrings = new ArrayList<>(queries);
208            return this;
209        }
210        
211        /**
212         * Add a filter query to the existing ones (as a String).
213         * @param query the filter query to add (as a String).
214         * @return The Searcher object itself. The Searcher object itself.
215         */
216        public Searcher addFilterQueryString(String query)
217        {
218            _filterQueryStrings.add(query);
219            return this;
220        }
221        
222        /**
223         * Set the filter queries (as {@link Query} objects).
224         * @param queries the filter queries (as {@link Query} objects).
225         * @return The Searcher object itself. The Searcher object itself.
226         */
227        public Searcher withFilterQueries(Query... queries)
228        {
229            _filterQueries = new ArrayList<>(queries.length);
230            CollectionUtils.addAll(_filterQueries, queries);
231            return this;
232        }
233        
234        /**
235         * Set the filter queries (as {@link Query} objects).
236         * @param queries the filter queries (as {@link Query} objects).
237         * @return The Searcher object itself. The Searcher object itself.
238         */
239        public Searcher withFilterQueries(Collection<Query> queries)
240        {
241            _filterQueries = new ArrayList<>(queries);
242            return this;
243        }
244        
245        /**
246         * Add a filter query to the existing ones (as a {@link Query} object).
247         * @param query the filter query to add (as a {@link Query} object).
248         * @return The Searcher object itself. The Searcher object itself.
249         */
250        public Searcher addFilterQuery(Query query)
251        {
252            _filterQueries.add(query);
253            return this;
254        }
255        
256        /**
257         * Set the sort clauses.
258         * @param sortClauses the sort clauses.
259         * @return The Searcher object itself.
260         */
261        public Searcher withSort(Sort... sortClauses)
262        {
263            _sortClauses = new ArrayList<>(sortClauses.length);
264            CollectionUtils.addAll(_sortClauses, sortClauses);
265            return this;
266        }
267        
268        /**
269         * Set the sort clauses.
270         * @param sortClauses the sort clauses.
271         * @return The Searcher object itself.
272         */
273        public Searcher withSort(List<Sort> sortClauses)
274        {
275            _sortClauses = new ArrayList<>(sortClauses);
276            return this;
277        }
278        
279        /**
280         * Add a sort clause to the existing ones.
281         * @param sortClause The sort clause to add.
282         * @return The Searcher object itself.
283         */
284        public Searcher addSort(Sort sortClause)
285        {
286            _sortClauses.add(sortClause);
287            return this;
288        }
289        
290        /**
291         * Set the faceted fields.
292         * @param facets the faceted fields.
293         * @return The Searcher object itself.
294         */
295        public Searcher withFacets(SearchField... facets)
296        {
297            _facets = new ArrayList<>(facets.length);
298            CollectionUtils.addAll(_facets, facets);
299            return this;
300        }
301        
302        /**
303         * Set the faceted fields.
304         * @param facets the faceted fields.
305         * @return The Searcher object itself.
306         */
307        public Searcher withFacets(Collection<SearchField> facets)
308        {
309            _facets = new ArrayList<>(facets);
310            return this;
311        }
312        
313        /**
314         * Add a faceted field.
315         * @param facet The faceted field to add.
316         * @return The Searcher object itself.
317         */
318        public Searcher addFacet(SearchField facet)
319        {
320            _facets.add(facet);
321            return this;
322        }
323        
324        /**
325         * Set the facet values.
326         * @param facetValues The facet values.
327         * @return The Searcher object itself.
328         */
329        public Searcher withFacetValues(Map<String, List<String>> facetValues)
330        {
331            _facetValues = new HashMap<>(facetValues);
332            return this;
333        }
334        
335        /**
336         * Set the search offset and limit.
337         * @param start The start index (offset).
338         * @param maxResults The maximum number of results.
339         * @return The Searcher object itself.
340         */
341        public Searcher withLimits(int start, int maxResults)
342        {
343            this._start = start;
344            this._maxResults = maxResults;
345            return this;
346        }
347        
348        /**
349         * Set the search context.
350         * @param searchContext The search context.
351         * @return The Searcher object itself.
352         */
353        public Searcher withContext(Map<String, Object> searchContext)
354        {
355            _searchContext = new HashMap<>(searchContext);
356            return this;
357        }
358        
359        /**
360         * Add a value to the search context.
361         * @param key The context key.
362         * @param value The value.
363         * @return The Searcher object itself.
364         */
365        public Searcher addContextElement(String key, Object value)
366        {
367            _searchContext.put(key, value);
368            return this;
369        }
370        
371        /**
372         * Whether to check rights when searching, false otherwise.
373         * @param checkRights <code>true</code> to check rights, <code>false</code> otherwise.
374         * @return The Searcher object itself.
375         */
376        public Searcher setCheckRights(boolean checkRights)
377        {
378            _checkRights = checkRights;
379            return this;
380        }
381        
382        /**
383         * Check rights when searching, <b>not</b> according to the current user, 
384         * but according to the given {@link AllowedUsers visibilty} to compare each
385         * result with.
386         * @param compareTo the {@link AllowedUsers visibilty} to compare each result with.
387         * @return The Searcher object itself.
388         */
389        public Searcher checkRightsComparingTo(AllowedUsers compareTo)
390        {
391            _checkRights = false;
392            _checkRightsComparingTo = compareTo;
393            return this;
394        }
395        
396        /**
397         * Sets the debug on the Solr query 
398         * @return The Searcher object itself.
399         */
400        public Searcher setDebugOn()
401        {
402            _debug = true;
403            return this;
404        }
405        
406        /**
407         * Execute the search with the current parameters.
408         * @param <A> The type of search results
409         * @return An iterable on the result ametys objects.
410         * @throws Exception If an error occurs.
411         */
412        public <A extends AmetysObject> AmetysObjectIterable<A> search() throws Exception
413        {
414            SearchResults<A> searchResults = searchWithFacets();
415            return searchResults.getObjects();
416        }
417        
418        /**
419         * Execute the search with the current parameters.
420         * @param <A> The type of search results
421         * @return An iterable on the search result objects.
422         * @throws Exception If an error occurs.
423         */
424        public <A extends AmetysObject> SearchResults<A> searchWithFacets() throws Exception
425        {
426            QueryResponse response = _querySolrServer();
427            return _buildResults(response, _facets);
428        }
429        
430        /**
431         * From the Solr server response, builds the {@link SearchResults} object.
432         * @param <A> The type of search results
433         * @param response The response from the Solr server
434         * @param facets The facet fields to return
435         * @return An iterable on the search result objects.
436         * @throws Exception If an error occurs.
437         */
438        protected <A extends AmetysObject> SearchResults<A> _buildResults(QueryResponse response, List<SearchField> facets) throws Exception
439        {
440            _handleDebug(response);
441            Map<String, Map<String, Integer>> facetResults = getFacetResults(response, facets);
442            return new SolrSearchResults<>(response, _resolver, facetResults);
443        }
444        
445        private void _handleDebug(QueryResponse response)
446        {
447            if (_debug && _logger.isDebugEnabled())
448            {
449                Map<String, Object> debugMap = response.getDebugMap();
450                _logger.debug("Debug response: \n{}", debugMap);
451            }
452        }
453        
454        private QueryResponse _querySolrServer() throws Exception
455        {
456            _logSearchQueries();
457            
458            Object query = getQuery();
459            List<Object> filterQueries = getFilterQueries();
460            
461            AmetysQueryRequest solrQuery = getSolrQuery(query, filterQueries, _start, _maxResults, _searchContext, _checkRights, _checkRightsComparingTo);
462            
463            // Set the sort specification and facets in the solr query object.
464            setSort(solrQuery, _sortClauses);
465            setFacets(solrQuery, _facets, _facetValues);
466            
467            modifySolrQuery(solrQuery);
468            
469            // Use POST to avoid problems with large requests.
470            return solrQuery.process(_solrClient, _solrClientProvider.getCollectionName());
471        }
472        
473        private void _logSearchQueries()
474        {
475            if (!_logger.isDebugEnabled())
476            {
477                return;
478            }
479            
480            if (_queryString == null && _query != null)
481            {
482                _logger.debug("Query before building: \n{}", _query.toString(0));
483            }
484            
485            if (!_filterQueries.isEmpty())
486            {
487                _logger.debug("Filter Queries before building: \n{}", _filterQueries
488                        .stream()
489                        .map(fq -> fq.toString(0))
490                        .collect(Collectors.joining("\n###\n")));
491            }
492        }
493        
494        /**
495         * Get the query string from the parameters.
496         * @return The query string.
497         * @throws QuerySyntaxException If the query is invalid.
498         */
499        protected Object getQuery() throws QuerySyntaxException
500        {
501            Object query = "*:*";
502            
503            if (_queryString != null)
504            {
505                query = _queryString;
506            }
507            else if (_query != null)
508            {
509                query = _query.buildAsJson();
510            }
511            
512            return query;
513        }
514        
515        /**
516         * Get the filter queries from the parameters.
517         * @return The list of filter queries.
518         * @throws QuerySyntaxException If one of the queries is invalid.
519         */
520        protected List<Object> getFilterQueries() throws QuerySyntaxException
521        {
522            List<Object> filterQueries = new ArrayList<>();
523            if (!_filterQueryStrings.isEmpty())
524            {
525                filterQueries.addAll(_filterQueryStrings);
526            }
527            
528            if (!_filterQueries.isEmpty())
529            {
530                for (Query fq : _filterQueries)
531                {
532                    // discard useless "*:*" filter queries
533                    if (!(fq instanceof MatchAllQuery))
534                    {
535                        filterQueries.add(fq.buildAsJson());
536                    }
537                }
538            }
539            
540            return filterQueries;
541        }
542        
543        /**
544         * Get the solr query object.
545         * @param query The solr query string.
546         * @param filterQueries The filter queries (as Strings).
547         * @param start The start index.
548         * @param maxResults The maximum number of results.
549         * @param searchContext The search context.
550         * @param checkRights Whether to check rights when searching or not.
551         * @param allowedUsersToCompare The {@link AllowedUsers} object to compare with for checking rights
552         * @return The solr query object.
553         * @throws Exception If an error occurs.
554         */
555        @SuppressWarnings("unchecked")
556        protected AmetysQueryRequest getSolrQuery(Object query, Collection<Object> filterQueries, int start, int maxResults, Map<String, Object> searchContext, boolean checkRights, AllowedUsers allowedUsersToCompare) throws Exception
557        {
558            AmetysQueryRequest solrQuery = new AmetysQueryRequest(_logger);
559            
560            if (query instanceof String q)
561            {
562                solrQuery.setQuery(StringUtils.isNotBlank(q) ? q : "*:*");
563            }
564            else if (query instanceof Map)
565            {
566                solrQuery.setQuery((Map<String, Object>) query);
567            }
568            
569            // Set the query string, pagination spec and fields to be returned.
570            if (start > 0)
571            {
572                solrQuery.setOffset(start);
573            }
574            
575            solrQuery.setLimit(maxResults);
576            
577            // Return only ID + score fields.
578            solrQuery.returnFields("id", "score");
579            
580            // Add filter queries.
581            for (Object fq : filterQueries)
582            {
583                if (fq instanceof String)
584                {
585                    solrQuery.withFilter((String) fq);
586                }
587                else if (fq instanceof Map)
588                {
589                    solrQuery.withFilter((Map<String, Object>) fq);
590                }
591            }
592            
593            if (checkRights)
594            {
595                _checkRightsQuery(solrQuery);
596            }
597            else if (allowedUsersToCompare != null)
598            {
599                _checkAllowedUsers(solrQuery, allowedUsersToCompare);
600            }
601            
602            if (_debug)
603            {
604                solrQuery.withParam(CommonParams.DEBUG, "true");
605            }
606            
607            return solrQuery;
608        }
609        
610        private void _checkRightsQuery(JsonQueryRequest solrQuery)
611        {
612            Map<String, Object> acl;
613            
614            UserIdentity user = _currentUserProvider.getUser();
615            if (user == null)
616            {
617                acl = Map.of("anonymous", "");
618            }
619            else
620            {
621                acl = new HashMap<>();
622                acl.put("populationId", user.getPopulationId());
623                acl.put("login", user.getLogin());
624                
625                Set<GroupIdentity> groups = _groupManager.getUserGroups(user);
626                if (!groups.isEmpty())
627                {
628                    String groupsAsStr = groups.stream()
629                            .map(GroupIdentity::groupIdentityToString)
630                            .collect(Collectors.joining(","));
631                    acl.put("groups", groupsAsStr);
632                }
633            }
634            
635            // {!acl anonymous=} or {!acl populationId=users login=user1 groups="group1#groupDirectory,group2#groupDirectory"} to check acl
636            solrQuery.withFilter(Map.of("acl", acl));
637        }
638        
639        private void _checkAllowedUsers(JsonQueryRequest solrQuery, AllowedUsers allowedUsersToCompare)
640        {
641            Map<String, Object> acl;
642            
643            if (allowedUsersToCompare.isAnonymousAllowed())
644            {
645                acl = Map.of("anonymous", "true");
646            }
647            else
648            {
649                acl = new HashMap<>();
650                
651                if (allowedUsersToCompare.isAnyConnectedUserAllowed())
652                {
653                    acl.put("anyConnected", "true");
654                }
655                
656                Set<String> allowedUsers = allowedUsersToCompare.getAllowedUsers().stream().map(UserIdentity::userIdentityToString).collect(Collectors.toSet());
657                Set<String> deniedUsers = allowedUsersToCompare.getDeniedUsers().stream().map(UserIdentity::userIdentityToString).collect(Collectors.toSet());
658                Set<String> allowedGroups = allowedUsersToCompare.getAllowedGroups().stream().map(GroupIdentity::groupIdentityToString).collect(Collectors.toSet());
659                Set<String> deniedGroups = allowedUsersToCompare.getDeniedGroups().stream().map(GroupIdentity::groupIdentityToString).collect(Collectors.toSet());
660                
661                if (!allowedUsers.isEmpty())
662                {
663                    acl.put("allowedUsers", String.join(",", allowedUsers));
664                }
665                
666                if (!deniedUsers.isEmpty())
667                {
668                    acl.put("deniedUsers", String.join(",", deniedUsers));
669                }
670                
671                if (!allowedGroups.isEmpty())
672                {
673                    acl.put("allowedGroups", String.join(",", allowedGroups));
674                }
675                
676                if (!deniedGroups.isEmpty())
677                {
678                    acl.put("deniedGroups", String.join(",", deniedGroups));
679                }
680            }
681            
682            solrQuery.withFilter(Map.of("aclCompare", acl));
683        }
684        
685        /**
686         * Set the sort definition in the solr query object.
687         * @param solrQuery The solr query object.
688         * @param sortCriteria The sort criteria.
689         */
690        protected void setSort(JsonQueryRequest solrQuery, List<Sort> sortCriteria)
691        {
692            if (sortCriteria.isEmpty())
693            {
694                solrQuery.setSort("score desc");
695            }
696            
697            String sort = sortCriteria.stream()
698                                      .map(sortCriterion -> sortCriterion.getField() + " " + (sortCriterion.getOrder() == Order.ASC ? "asc" : "desc"))
699                                      .collect(Collectors.joining(","));
700            
701            if (StringUtils.isNotBlank(sort))
702            {
703                solrQuery.setSort(sort);
704            }
705        }
706        
707        /**
708         * Set the facet definition in the solr query object and return a mapping from solr field name to criterion ID.
709         * @param solrQuery the solr query object to fill.
710         * @param facets The facet definitions to use.
711         * @param facetValues the facet values.
712         * @throws QuerySyntaxException if there's a syntax error in queries
713         */
714        protected void setFacets(JsonQueryRequest solrQuery, Collection<SearchField> facets, Map<String, List<String>> facetValues) throws QuerySyntaxException
715        {
716            List<String> joinedFacets = new ArrayList<>();
717            for (SearchField facetField : facets)
718            {
719                String fieldName = facetField.getName();
720                String solrFieldName = facetField.getFacetField();
721                
722                if (StringUtils.isNotBlank(fieldName))
723                {
724                    if (facetField.isJoined())
725                    {
726                        _setJoinedFacet(solrQuery, fieldName, facetField.getJoinedPaths(), solrFieldName, facetField.getFacetFunction(), facetValues, joinedFacets);
727                    }
728                    else
729                    {
730                        _setNonJoinedFacet(solrQuery, fieldName, solrFieldName, facetValues);
731                    }
732                }
733            }
734        }
735        
736        private void _setJoinedFacet(JsonQueryRequest solrQuery, String fieldName, List<String> joinedPaths, String solrFieldName, String facetFunction, Map<String, List<String>> facetValues, List<String> joinedFacets) throws QuerySyntaxException
737        {
738            List<String> fieldFacetValues = facetValues.get(fieldName);
739            if (fieldFacetValues != null && !fieldFacetValues.isEmpty())
740            {
741                List<Query> facetQueries = new ArrayList<>();
742                for (String facetValue : fieldFacetValues)
743                {
744                    facetQueries.add(() -> solrFieldName + ":\"" + facetValue + '"');
745                }
746                
747                JoinQuery joinQuery = new JoinQuery(new OrQuery(facetQueries), joinedPaths);
748                
749                solrQuery.withFilter(Map.of("#" + fieldName, joinQuery.buildAsJson()));
750            }
751            
752            solrQuery.withParam("facet", "true");
753            joinedFacets.add("{!ex=" + fieldName + " key=" + fieldName + "}" + facetFunction);
754            solrQuery.withParam("facet.function", joinedFacets);
755        }
756        
757        private void _setNonJoinedFacet(JsonQueryRequest solrQuery, String fieldName, String solrFieldName, Map<String, List<String>> facetValues) throws QuerySyntaxException
758        {
759            List<String> fieldFacetValues = facetValues.get(fieldName);
760            if (fieldFacetValues != null && !fieldFacetValues.isEmpty())
761            {
762                List<Query> facetQueries = new ArrayList<>();
763                for (String facetValue : fieldFacetValues)
764                {
765                    facetQueries.add(() -> solrFieldName + ":\"" + facetValue + '"');
766                }
767                
768                solrQuery.withFilter(Map.of("#" + fieldName, new OrQuery(facetQueries).buildAsJson()));
769            }
770            
771            TermsFacetMap facetMap = new TermsFacetMap(solrFieldName).setLimit(-1)
772                                                                     .withDomain(new DomainMap().withTagsToExclude(fieldName));
773            
774            solrQuery.withFacet(fieldName, facetMap);
775        }
776        
777        /**
778         * Retrieve the facet results from the solr response.
779         * @param response the solr response.
780         * @param facets The facet fields to return.
781         * @return the facet results.
782         */
783        protected Map<String, Map<String, Integer>> getFacetResults(QueryResponse response, Collection<SearchField> facets)
784        {
785            Map<String, Map<String, Integer>> facetResults = new LinkedHashMap<>();
786            
787            for (SearchField facetField : facets)
788            {
789                String fieldName = facetField.getName();
790
791                if (!facetField.isJoined())
792                {
793                    BucketBasedJsonFacet facet = response.getJsonFacetingResponse().getBucketBasedFacets(fieldName);
794                    
795                    List<BucketJsonFacet> values = facet.getBuckets();
796                    
797                    Map<String, Integer> solrFacetValues = new HashMap<>();
798                    facetResults.put(fieldName, solrFacetValues);
799                    
800                    for (BucketJsonFacet value : values)
801                    {
802                        solrFacetValues.put(value.getVal().toString(), (int) value.getCount());
803                    }
804                }
805                else
806                {
807                    FacetField solrFacetField = response.getFacetField(fieldName);
808                     
809                    List<Count> values = solrFacetField.getValues();
810                     
811                    Map<String, Integer> solrFacetValues = new HashMap<>();
812                    facetResults.put(fieldName, solrFacetValues);
813                                     
814                    for (Count count : values)
815                    {
816                        solrFacetValues.put(count.getName(), (int) count.getCount());
817                    }
818                }
819            }
820            
821            return facetResults;
822        }
823        
824        /**
825         * Template method to do additional operations on the Solr query before passing it to the Solr client
826         * @param query the Solr query
827         */
828        protected void modifySolrQuery(JsonQueryRequest query)
829        {
830            // do nothing by default
831        }
832    }
833    
834    static class AmetysQueryRequest extends JsonQueryRequest
835    {
836        private Logger _logger;
837        
838        public AmetysQueryRequest(Logger logger)
839        {
840            super();
841            _logger = logger;
842        }
843        
844        @Override
845        public ContentWriter getContentWriter(String expectedType)
846        {
847            ContentWriter writer = super.getContentWriter(expectedType);
848            
849            if (!_logger.isInfoEnabled())
850            {
851                return writer;
852            }
853            
854            return new ContentWriter()
855            {
856                public void write(OutputStream os) throws IOException
857                {
858                    ByteArrayOutputStream baos = new ByteArrayOutputStream();
859                    writer.write(baos);
860                    
861                    _logger.info("Solr query:\n" + baos.toString(StandardCharsets.UTF_8));
862                    
863                    os.write(baos.toByteArray(), 0, baos.size());
864                }
865                
866                public String getContentType()
867                {
868                    return writer.getContentType();
869                }
870            };
871        }
872    }
873}