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