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