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