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