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.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        Predicate<Query> isMatchNone = MatchNoneQuery.class::isInstance;
082        List<Query> queriesWithoutMatchNone = _queries
083                .stream()
084                .filter(isMatchNone.negate())
085                .collect(Collectors.toList());
086        if (!_queries.isEmpty() && queriesWithoutMatchNone.isEmpty())
087        {
088            // special case where all are MatchNone
089            return new MatchNoneQuery().build();
090        }
091        
092        boolean isFirst = true;
093        StringBuilder sb = new StringBuilder();
094        
095        for (Query subQuery : queriesWithoutMatchNone)
096        {
097            if (subQuery instanceof MatchAllQuery)
098            {
099                // short-circuit => all docs will match
100                return new MatchAllQuery().build();
101            }
102            else if (subQuery != null)
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        
117        if (isFirst)
118        {
119            return "";
120        }
121        else
122        {
123            return sb.toString();
124        }
125    }
126    
127    public Object buildAsJson() throws QuerySyntaxException
128    {
129        Query rewrittenQuery = rewrite();
130        
131        if (!(rewrittenQuery instanceof OrQuery orQuery))
132        {
133            return rewrittenQuery.buildAsJson();
134        }
135        
136        List<Query> rewrittenQueries = orQuery.getQueries();
137        
138        // filter out empty queries
139        List<Object> builtQueries = rewrittenQueries.stream()
140                                                    .map(LambdaUtils.wrap(Query::buildAsJson))
141                                                    .filter(q -> !(q instanceof String s) || !StringUtils.isBlank(s))
142                                                    .toList();
143
144        if (builtQueries.isEmpty())
145        {
146            return new MatchAllQuery().buildAsJson();
147        }
148        
149        if (builtQueries.size() == 1)
150        {
151            return builtQueries.get(0);
152        }
153
154        return Map.of("bool", Map.of(Query.BOOL_SHOULD, builtQueries));
155    }
156    
157    public Query rewrite()
158    {
159        List<Query> rewrittenQueries = _queries.stream()
160                .filter(Objects::nonNull)
161                .map(Query::rewrite).toList();
162        
163        Set<Query> queries = new HashSet<>();
164        for (Query q : rewrittenQueries)
165        {
166            // do not use instanceof here, as we don't want to match OrQuery subclasses
167            if (q.getClass().getName().equals(OrQuery.class.getName()))
168            {
169                queries.addAll(((OrQuery) q).getQueries());
170            }
171            else if (q instanceof MatchAllQuery)
172            {
173                return new MatchAllQuery();
174            }
175            else if (!(q instanceof MatchNoneQuery))
176            {
177                queries.add(q);
178            }
179        }
180
181        if (queries.isEmpty())
182        {
183            return new MatchAllQuery();
184        }
185        
186        if (queries.size() == 1)
187        {
188            return queries.iterator().next();
189        }
190        
191        return new OrQuery(queries);
192    }
193    
194    @Override
195    public String toString(int indent)
196    {
197        String tagName = _tagNameForToString();
198        final String orLineIndent = StringUtils.repeat(' ', indent);
199        final int subIndent = indent + 2;
200        final String subQueries = _queries
201                .stream()
202                .filter(Objects::nonNull)
203                .map(sq -> sq.toString(subIndent))
204                .collect(Collectors.joining("\n"));
205        return orLineIndent + "[" + tagName + "]\n" + subQueries + "\n" + orLineIndent + "[/" + tagName + "]";
206    }
207    
208    /**
209     * The tag name for {@link #toString(int)} debug method.
210     * @return The tag name for {@link #toString(int)} debug method.
211     */
212    protected String _tagNameForToString()
213    {
214        return "OR";
215    }
216
217    @Override
218    public int hashCode()
219    {
220        return _queries.hashCode();
221    }
222
223    @Override
224    public boolean equals(Object obj)
225    {
226        if (this == obj)
227        {
228            return true;
229        }
230        
231        if (obj == null || getClass() != obj.getClass())
232        {
233            return false;
234        }
235        
236        OrQuery other = (OrQuery) obj;
237        return Objects.equals(_queries, other._queries);
238    }
239}