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