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