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