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        boolean matchNone = mustQueries.stream().anyMatch(MatchNoneQuery.class::isInstance)
158                         || filterQueries.stream().anyMatch(MatchNoneQuery.class::isInstance)
159                         || mustNotQueries.stream().anyMatch(MatchAllQuery.class::isInstance);
160        
161        if (matchNone)
162        {
163            return new MatchNoneQuery().buildAsJson();
164        }
165        
166        // filter out empty queries
167        List<Object> builtMustQueries = _jsonifyQueries(mustQueries);
168        List<Object> builtFilterQueries = _jsonifyQueries(filterQueries);
169        List<Object> builtMustNotQueries = _jsonifyQueries(mustNotQueries);
170        
171        boolean noPositiveQueries = builtMustQueries.isEmpty() && builtFilterQueries.isEmpty();
172
173        if (noPositiveQueries && builtMustNotQueries.isEmpty())
174        {
175            return Optional.empty();
176        }
177        
178        if (builtMustQueries.size() == 1 && builtFilterQueries.isEmpty() && builtMustNotQueries.isEmpty())
179        {
180            return Optional.of(builtMustQueries.get(0));
181        }
182        
183        Map<String, Object> clauses = new HashedMap<>();
184        if (!builtMustQueries.isEmpty())
185        {
186            clauses.put(Query.BOOL_MUST, builtMustQueries);
187        }
188        
189        if (!builtFilterQueries.isEmpty())
190        {
191            clauses.put(Query.BOOL_FILTER, builtFilterQueries);
192        }
193               
194        if (!builtMustNotQueries.isEmpty())
195        {
196            if (noPositiveQueries)
197            {
198                clauses.put(Query.BOOL_MUST, "*:*");
199            }
200            
201            clauses.put(Query.BOOL_MUST_NOT, builtMustNotQueries);
202        }
203        
204        return Optional.of(Map.of("bool", clauses));
205    }
206    
207    private List<Object> _jsonifyQueries(Collection<Query> queries)
208    {
209        return queries.stream()
210                      .map(LambdaUtils.wrap(Query::buildAsJson))
211                      .flatMap(Optional::stream)
212                      .filter(__NOT_EMPTY_QUERY)
213                      .toList();
214    }
215    
216    private void _dispatchClauses(List<Query> queries, Set<Query> mustQueries, Set<Query> filterQueries, Set<Query> mustNotQueries)
217    {
218        // dispatch queries by nature, while discarding useless clauses
219        for (Query q : queries)
220        {
221            if (q instanceof ConstantNilScoreQuery filterQuery)
222            {
223                Query filteredQuery = filterQuery.getSubQuery();
224                if (!(filteredQuery instanceof MatchAllQuery))
225                {
226                    filterQueries.add(filteredQuery);
227                }
228            }
229            else if (q instanceof NotQuery notQuery)
230            {
231                Query negatedQuery = notQuery.getSubQuery();
232                if (negatedQuery instanceof OrQuery orQuery)
233                {
234                    mustNotQueries.addAll(orQuery.getQueries());
235                }
236                else if (!(negatedQuery instanceof MatchNoneQuery))
237                {
238                    mustNotQueries.add(negatedQuery);
239                }
240            }
241            else if (!(q instanceof MatchAllQuery))
242            {
243                mustQueries.add(q);
244            }
245        }
246    }
247    
248    public Optional<Query> rewrite()
249    {
250        boolean matchAll = false;
251        Set<Query> queries = new LinkedHashSet<>();
252        for (Query orignalQuery : _queries)
253        {
254            if (orignalQuery != null)
255            {
256                Optional<Query> rewrite = orignalQuery.rewrite();
257                if (rewrite.isPresent())
258                {
259                    Query rewrittenQuery = rewrite.get();
260                    // do not use instanceof here, as we don't want to match AndQuery subclasses
261                    if (rewrittenQuery.getClass().getName().equals(AndQuery.class.getName()))
262                    {
263                        queries.addAll(((AndQuery) rewrittenQuery).getQueries());
264                    }
265                    else if (rewrittenQuery instanceof MatchNoneQuery)
266                    {
267                        return Optional.of(new MatchNoneQuery());
268                    }
269                    else if (rewrittenQuery instanceof MatchAllQuery)
270                    {
271                        matchAll = true;
272                    }
273                    else
274                    {
275                        queries.add(rewrittenQuery);
276                    }
277                }
278            }
279        }
280        
281        if (queries.isEmpty())
282        {
283            return matchAll ? Optional.of(new MatchAllQuery()) : Optional.empty();
284        }
285        
286        if (queries.size() == 1)
287        {
288            return Optional.of(queries.iterator().next());
289        }
290        
291        return Optional.of(new AndQuery(queries));
292    }
293    
294    @Override
295    public String toString(int indent)
296    {
297        final String andLineIndent = StringUtils.repeat(' ', indent);
298        final int subIndent = indent + 2;
299        final String subQueries = _queries
300                .stream()
301                .filter(Objects::nonNull)
302                .map(sq -> sq.toString(subIndent))
303                .collect(Collectors.joining("\n"));
304        return andLineIndent + "[AND]\n" + subQueries + "\n" + andLineIndent + "[/AND]";
305    }
306    
307    @Override
308    public int hashCode()
309    {
310        return _queries.hashCode();
311    }
312
313    @Override
314    public boolean equals(Object obj)
315    {
316        if (this == obj)
317        {
318            return true;
319        }
320        
321        if (obj == null || getClass() != obj.getClass())
322        {
323            return false;
324        }
325        
326        AndQuery other = (AndQuery) obj;
327        return Objects.equals(_queries, other._queries);
328    }
329}