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.lang3.StringUtils;
032
033import org.ametys.core.util.LambdaUtils;
034
035/**
036 * Represents a search {@link Query} corresponding to the logical "or" between several other queries.
037 */
038public class OrQuery implements Query
039{
040    /** The list of queries. The queries on this list are distinct. */
041    protected List<Query> _queries;
042    
043    /**
044     * Build an OrQuery object.
045     * @param queries the queries.
046     */
047    public OrQuery(Query... queries)
048    {
049        this(Arrays.asList(queries));
050    }
051    
052    /**
053     * Build an OrQuery object.
054     * @param queries the queries as a Collection.
055     */
056    public OrQuery(Collection<Query> queries)
057    {
058        _queries = queries.stream().distinct().toList();
059    }
060    
061    /**
062     * Returns a {@link Collector} which collects {@link Query Queries} into an OR query
063     * @return a {@link Collector} which collects {@link Query Queries} into an OR query
064     */
065    public static Collector<Query, ?, OrQuery> collector()
066    {
067        return LambdaUtils.Collectors.withListAccumulation(OrQuery::new);
068    }
069    
070    /**
071     * Get the list of queries in this "or".
072     * @return the list of queries.
073     */
074    public List<Query> getQueries()
075    {
076        return Collections.unmodifiableList(_queries);
077    }
078    
079    @Override
080    public String build() throws QuerySyntaxException
081    {
082        Predicate<Query> isMatchNone = MatchNoneQuery.class::isInstance;
083        List<Query> queriesWithoutMatchNone = _queries
084                .stream()
085                .filter(isMatchNone.negate())
086                .collect(Collectors.toList());
087        if (!_queries.isEmpty() && queriesWithoutMatchNone.isEmpty())
088        {
089            // special case where all are MatchNone
090            return new MatchNoneQuery().build();
091        }
092        
093        boolean isFirst = true;
094        StringBuilder sb = new StringBuilder();
095        
096        for (Query subQuery : queriesWithoutMatchNone)
097        {
098            if (subQuery instanceof MatchAllQuery)
099            {
100                // short-circuit => all docs will match
101                return new MatchAllQuery().build();
102            }
103            else if (subQuery != null)
104            {
105                String exprAsString = subQuery.build();
106                if (StringUtils.isNotBlank(exprAsString))
107                {
108                    if (!isFirst)
109                    {
110                        sb.append(" OR ");
111                    }
112                    sb.append("(").append(exprAsString).append(")");
113                    isFirst = false;
114                }
115            }
116        }
117        
118        if (isFirst)
119        {
120            return "";
121        }
122        else
123        {
124            return sb.toString();
125        }
126    }
127    
128    public Optional<Object> buildAsJson() throws QuerySyntaxException
129    {
130        Optional<Query> rewrittenQueryAsOptional = rewrite();
131        if (rewrittenQueryAsOptional.isEmpty())
132        {
133            return Optional.empty();
134        }
135        
136        Query rewrittenQuery = rewrittenQueryAsOptional.get();
137        if (!(rewrittenQuery instanceof OrQuery orQuery))
138        {
139            return rewrittenQuery.buildAsJson();
140        }
141        
142        List<Query> rewrittenQueries = orQuery.getQueries();
143        
144        // filter out empty queries
145        List<Object> builtQueries = rewrittenQueries.stream()
146                                                    .map(LambdaUtils.wrap(Query::buildAsJson))
147                                                    .flatMap(Optional::stream)
148                                                    .filter(q -> !(q instanceof String s) || !StringUtils.isBlank(s))
149                                                    .toList();
150
151        if (builtQueries.isEmpty())
152        {
153            return Optional.empty();
154        }
155        
156        if (builtQueries.size() == 1)
157        {
158            return Optional.of(builtQueries.get(0));
159        }
160
161        return Optional.of(Map.of("bool", Map.of(Query.BOOL_SHOULD, builtQueries)));
162    }
163    
164    public Optional<Query> rewrite()
165    {
166        List<Query> rewrittenQueries = _queries.stream()
167                .filter(Objects::nonNull)
168                .map(Query::rewrite)
169                .flatMap(Optional::stream)
170                .toList();
171        
172        Set<Query> queries = new HashSet<>();
173        for (Query q : rewrittenQueries)
174        {
175            // do not use instanceof here, as we don't want to match OrQuery subclasses
176            if (q.getClass().getName().equals(OrQuery.class.getName()))
177            {
178                queries.addAll(((OrQuery) q).getQueries());
179            }
180            else if (q instanceof MatchAllQuery)
181            {
182                return Optional.of(new MatchAllQuery());
183            }
184            else if (!(q instanceof MatchNoneQuery))
185            {
186                queries.add(q);
187            }
188        }
189
190        if (queries.isEmpty())
191        {
192            return Optional.empty();
193        }
194        
195        if (queries.size() == 1)
196        {
197            return Optional.of(queries.iterator().next());
198        }
199        
200        return Optional.of(new OrQuery(queries));
201    }
202    
203    @Override
204    public String toString(int indent)
205    {
206        String tagName = _tagNameForToString();
207        final String orLineIndent = StringUtils.repeat(' ', indent);
208        final int subIndent = indent + 2;
209        final String subQueries = _queries
210                .stream()
211                .filter(Objects::nonNull)
212                .map(sq -> sq.toString(subIndent))
213                .collect(Collectors.joining("\n"));
214        return orLineIndent + "[" + tagName + "]\n" + subQueries + "\n" + orLineIndent + "[/" + tagName + "]";
215    }
216    
217    /**
218     * The tag name for {@link #toString(int)} debug method.
219     * @return The tag name for {@link #toString(int)} debug method.
220     */
221    protected String _tagNameForToString()
222    {
223        return "OR";
224    }
225
226    @Override
227    public int hashCode()
228    {
229        return _queries.hashCode();
230    }
231
232    @Override
233    public boolean equals(Object obj)
234    {
235        if (this == obj)
236        {
237            return true;
238        }
239        
240        if (obj == null || getClass() != obj.getClass())
241        {
242            return false;
243        }
244        
245        OrQuery other = (OrQuery) obj;
246        return Objects.equals(_queries, other._queries);
247    }
248}