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