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}