001/*
002 *  Copyright 2017 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.query;
017
018import java.util.Arrays;
019import java.util.Collection;
020import java.util.Collections;
021import java.util.HashSet;
022import java.util.LinkedHashSet;
023import java.util.List;
024import java.util.Map;
025import java.util.Objects;
026import java.util.Optional;
027import java.util.Set;
028import java.util.function.Predicate;
029import java.util.stream.Collector;
030import java.util.stream.Collectors;
031
032import org.apache.commons.collections4.map.HashedMap;
033import org.apache.commons.lang3.StringUtils;
034
035import org.ametys.core.util.LambdaUtils;
036
037/**
038 * Represents a search {@link Query} corresponding to the logical "and" between several other queries.
039 */
040public class AndQuery implements Query
041{
042    private static final Predicate<Object> __NOT_EMPTY_QUERY = q -> !(q instanceof String s) || !StringUtils.isBlank(s);
043    
044    /** The list of queries. The queries on this list are distinct. */
045    protected List<Query> _queries;
046    
047    /**
048     * Build an AndQuery object.
049     * @param queries the queries.
050     */
051    public AndQuery(Query... queries)
052    {
053        this(Arrays.asList(queries));
054    }
055    
056    /**
057     * Build an AndQuery object.
058     * @param queries the queries as a Collection.
059     */
060    public AndQuery(Collection<Query> queries)
061    {
062        _queries = queries.stream().distinct().toList();
063    }
064    
065    /**
066     * Returns a {@link Collector} which collects {@link Query Queries} into an AND query
067     * @return a {@link Collector} which collects {@link Query Queries} into an AND query
068     */
069    public static Collector<Query, ?, AndQuery> collector()
070    {
071        return LambdaUtils.Collectors.withListAccumulation(AndQuery::new);
072    }
073    
074    /**
075     * Get the list of queries in this "and".
076     * @return the list of queries.
077     */
078    public List<Query> getQueries()
079    {
080        return Collections.unmodifiableList(_queries);
081    }
082    
083    @Override
084    public String build() throws QuerySyntaxException
085    {
086        // Optimize before building to build the simplest query possible
087        Optional<Query> rewrittenQueryAsOptional = rewrite();
088        if (rewrittenQueryAsOptional.isEmpty())
089        {
090            return "";
091        }
092        
093        // handles case where query has been converted to match none, match all, or single query
094        Query rewrittenQuery = rewrittenQueryAsOptional.get();
095        if (!(rewrittenQuery instanceof AndQuery andQuery))
096        {
097            return rewrittenQuery.build();
098        }
099        
100        // At this point we know that the query contains only non-null queries
101        // but they can still build to a blank query
102        List<Query> rewrittenQueries = andQuery.getQueries();
103        
104        boolean isFirst = true;
105        StringBuilder sb = new StringBuilder();
106        
107        for (Query subQuery : rewrittenQueries)
108        {
109            String exprAsString = subQuery.build();
110            if (StringUtils.isNotBlank(exprAsString))
111            {
112                if (!isFirst)
113                {
114                    sb.append(" AND ");
115                }
116                sb.append("(").append(exprAsString).append(")");
117                isFirst = false;
118            }
119        }
120        
121        if (isFirst)
122        {
123            return "";
124        }
125        else
126        {
127            return sb.toString();
128        }
129    }
130    
131    public Optional<Object> buildAsJson() throws QuerySyntaxException
132    {
133        // Optimize before building to build the simplest query possible
134        Optional<Query> rewrittenQueryAsOptional = rewrite();
135        if (rewrittenQueryAsOptional.isEmpty())
136        {
137            return Optional.empty();
138        }
139        
140        // handles case where query has been converted to match none, match all, or single query
141        Query rewrittenQuery = rewrittenQueryAsOptional.get();
142        if (!(rewrittenQuery instanceof AndQuery andQuery))
143        {
144            return rewrittenQuery.buildAsJson();
145        }
146        
147        // At this point we know that the query contains only non-null queries
148        // but they can still build to a blank query
149        List<Query> rewrittenQueries = andQuery.getQueries();
150        
151        Set<Query> mustQueries = new HashSet<>();
152        Set<Query> filterQueries = new HashSet<>();
153        Set<Query> mustNotQueries = new HashSet<>();
154        
155        _dispatchClauses(rewrittenQueries, mustQueries, filterQueries, mustNotQueries);
156        
157        if (_matchNone(mustQueries, filterQueries, mustNotQueries))
158        {
159            return new MatchNoneQuery().buildAsJson();
160        }
161        
162        // filter out empty queries
163        List<Object> builtMustQueries = _jsonifyQueries(mustQueries);
164        List<Object> builtFilterQueries = _jsonifyQueries(filterQueries);
165        List<Object> builtMustNotQueries = _jsonifyQueries(mustNotQueries);
166        
167        boolean noPositiveQueries = builtMustQueries.isEmpty() && builtFilterQueries.isEmpty();
168
169        if (noPositiveQueries && builtMustNotQueries.isEmpty())
170        {
171            return Optional.empty();
172        }
173        
174        if (builtMustQueries.size() == 1 && builtFilterQueries.isEmpty() && builtMustNotQueries.isEmpty())
175        {
176            return Optional.of(builtMustQueries.get(0));
177        }
178        
179        Map<String, Object> clauses = _jsonifyClauses(builtMustQueries, builtFilterQueries, builtMustNotQueries, noPositiveQueries);
180        
181        return Optional.of(Map.of("bool", clauses));
182    }
183    
184    private boolean _matchNone(Set<Query> mustQueries, Set<Query> filterQueries, Set<Query> mustNotQueries)
185    {
186        return mustQueries.stream().anyMatch(MatchNoneQuery.class::isInstance)
187                || filterQueries.stream().anyMatch(MatchNoneQuery.class::isInstance)
188                || mustNotQueries.stream().anyMatch(MatchAllQuery.class::isInstance);
189    }
190    
191    private Map<String, Object> _jsonifyClauses(List<Object> builtMustQueries, List<Object> builtFilterQueries, List<Object> builtMustNotQueries, boolean noPositiveQueries)
192    {
193        Map<String, Object> clauses = new HashedMap<>();
194        if (!builtMustQueries.isEmpty())
195        {
196            clauses.put(Query.BOOL_MUST, builtMustQueries);
197        }
198        
199        if (!builtFilterQueries.isEmpty())
200        {
201            clauses.put(Query.BOOL_FILTER, builtFilterQueries);
202        }
203               
204        if (!builtMustNotQueries.isEmpty())
205        {
206            if (noPositiveQueries)
207            {
208                clauses.put(Query.BOOL_MUST, "*:*");
209            }
210            
211            clauses.put(Query.BOOL_MUST_NOT, builtMustNotQueries);
212        }
213        return clauses;
214    }
215    
216    private List<Object> _jsonifyQueries(Collection<Query> queries)
217    {
218        return queries.stream()
219                      .map(LambdaUtils.wrap(Query::buildAsJson))
220                      .flatMap(Optional::stream)
221                      .filter(__NOT_EMPTY_QUERY)
222                      .toList();
223    }
224    
225    private void _dispatchClauses(List<Query> queries, Set<Query> mustQueries, Set<Query> filterQueries, Set<Query> mustNotQueries)
226    {
227        // dispatch queries by nature, while discarding useless clauses
228        for (Query q : queries)
229        {
230            if (q instanceof ConstantNilScoreQuery filterQuery)
231            {
232                Query filteredQuery = filterQuery.getSubQuery();
233                if (!(filteredQuery instanceof MatchAllQuery))
234                {
235                    filterQueries.add(filteredQuery);
236                }
237            }
238            else if (q instanceof NotQuery notQuery)
239            {
240                Query negatedQuery = notQuery.getSubQuery();
241                if (negatedQuery instanceof OrQuery orQuery)
242                {
243                    mustNotQueries.addAll(orQuery.getQueries());
244                }
245                else if (!(negatedQuery instanceof MatchNoneQuery))
246                {
247                    mustNotQueries.add(negatedQuery);
248                }
249            }
250            else if (!(q instanceof MatchAllQuery))
251            {
252                mustQueries.add(q);
253            }
254        }
255    }
256    
257    public Optional<Query> rewrite()
258    {
259        boolean matchAll = false;
260        Set<Query> queries = new LinkedHashSet<>();
261        for (Query orignalQuery : _queries)
262        {
263            if (orignalQuery != null)
264            {
265                Optional<Query> rewrite = orignalQuery.rewrite();
266                if (rewrite.isPresent())
267                {
268                    Query rewrittenQuery = rewrite.get();
269                    // do not use instanceof here, as we don't want to match AndQuery subclasses
270                    if (rewrittenQuery.getClass().getName().equals(AndQuery.class.getName()))
271                    {
272                        queries.addAll(((AndQuery) rewrittenQuery).getQueries());
273                    }
274                    else if (rewrittenQuery instanceof MatchNoneQuery)
275                    {
276                        return Optional.of(new MatchNoneQuery());
277                    }
278                    else if (rewrittenQuery instanceof MatchAllQuery)
279                    {
280                        matchAll = true;
281                    }
282                    else
283                    {
284                        queries.add(rewrittenQuery);
285                    }
286                }
287            }
288        }
289        
290        if (queries.isEmpty())
291        {
292            return matchAll ? Optional.of(new MatchAllQuery()) : Optional.empty();
293        }
294        
295        if (queries.size() == 1)
296        {
297            return Optional.of(queries.iterator().next());
298        }
299        
300        return Optional.of(new AndQuery(queries));
301    }
302    
303    @Override
304    public String toString(int indent)
305    {
306        final String andLineIndent = StringUtils.repeat(' ', indent);
307        final int subIndent = indent + 2;
308        final String subQueries = _queries
309                .stream()
310                .filter(Objects::nonNull)
311                .map(sq -> sq.toString(subIndent))
312                .collect(Collectors.joining("\n"));
313        return andLineIndent + "[AND]\n" + subQueries + "\n" + andLineIndent + "[/AND]";
314    }
315    
316    @Override
317    public int hashCode()
318    {
319        return _queries.hashCode();
320    }
321
322    @Override
323    public boolean equals(Object obj)
324    {
325        if (this == obj)
326        {
327            return true;
328        }
329        
330        if (obj == null || getClass() != obj.getClass())
331        {
332            return false;
333        }
334        
335        AndQuery other = (AndQuery) obj;
336        return Objects.equals(_queries, other._queries);
337    }
338}