/* * SPDX-License-Identifier: Apache-2.0 * * The OpenSearch Contributors require contributions made to * this file be licensed under the Apache-2.0 license or a * compatible open source license. */ /* * Licensed to Elasticsearch under one or more contributor * license agreements. See the NOTICE file distributed with * this work for additional information regarding copyright * ownership. Elasticsearch licenses this file to you under * the Apache License, Version 2.0 (the "License"); you may * not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ /* * Modifications Copyright OpenSearch Contributors. See * GitHub history for details. */ package org.opensearch.index.query; import org.apache.lucene.search.BooleanClause; import org.apache.lucene.search.BooleanClause.Occur; import org.apache.lucene.search.BooleanQuery; import org.apache.lucene.search.MatchAllDocsQuery; import org.apache.lucene.search.Query; import org.opensearch.core.ParseField; import org.opensearch.core.common.ParsingException; import org.opensearch.core.common.io.stream.StreamInput; import org.opensearch.core.common.io.stream.StreamOutput; import org.opensearch.common.lucene.search.Queries; import org.opensearch.core.xcontent.ObjectParser; import org.opensearch.core.xcontent.XContentBuilder; import org.opensearch.core.xcontent.XContentParser; import java.io.IOException; import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.function.Consumer; import java.util.stream.Stream; import static org.opensearch.common.lucene.search.Queries.fixNegativeQueryIfNeeded; /** * A Query that matches documents matching boolean combinations of other queries. * * @opensearch.internal */ public class BoolQueryBuilder extends AbstractQueryBuilder { public static final String NAME = "bool"; public static final boolean ADJUST_PURE_NEGATIVE_DEFAULT = true; private static final ParseField MUST_NOT = new ParseField("must_not").withDeprecation("mustNot"); private static final ParseField FILTER = new ParseField("filter"); private static final ParseField SHOULD = new ParseField("should"); private static final ParseField MUST = new ParseField("must"); private static final ParseField MINIMUM_SHOULD_MATCH = new ParseField("minimum_should_match"); private static final ParseField ADJUST_PURE_NEGATIVE = new ParseField("adjust_pure_negative"); private final List mustClauses = new ArrayList<>(); private final List mustNotClauses = new ArrayList<>(); private final List filterClauses = new ArrayList<>(); private final List shouldClauses = new ArrayList<>(); private boolean adjustPureNegative = ADJUST_PURE_NEGATIVE_DEFAULT; private String minimumShouldMatch; /** * Build an empty bool query. */ public BoolQueryBuilder() {} /** * Read from a stream. */ public BoolQueryBuilder(StreamInput in) throws IOException { super(in); mustClauses.addAll(readQueries(in)); mustNotClauses.addAll(readQueries(in)); shouldClauses.addAll(readQueries(in)); filterClauses.addAll(readQueries(in)); adjustPureNegative = in.readBoolean(); minimumShouldMatch = in.readOptionalString(); } @Override protected void doWriteTo(StreamOutput out) throws IOException { writeQueries(out, mustClauses); writeQueries(out, mustNotClauses); writeQueries(out, shouldClauses); writeQueries(out, filterClauses); out.writeBoolean(adjustPureNegative); out.writeOptionalString(minimumShouldMatch); } /** * Adds a query that must appear in the matching documents and will * contribute to scoring. No {@code null} value allowed. */ public BoolQueryBuilder must(QueryBuilder queryBuilder) { if (queryBuilder == null) { throw new IllegalArgumentException("inner bool query clause cannot be null"); } mustClauses.add(queryBuilder); return this; } /** * Gets the queries that must appear in the matching documents. */ public List must() { return this.mustClauses; } /** * Adds a query that must appear in the matching documents but will * not contribute to scoring. No {@code null} value allowed. */ public BoolQueryBuilder filter(QueryBuilder queryBuilder) { if (queryBuilder == null) { throw new IllegalArgumentException("inner bool query clause cannot be null"); } filterClauses.add(queryBuilder); return this; } /** * Gets the queries that must appear in the matching documents but don't contribute to scoring */ public List filter() { return this.filterClauses; } /** * Adds a query that must not appear in the matching documents. * No {@code null} value allowed. */ public BoolQueryBuilder mustNot(QueryBuilder queryBuilder) { if (queryBuilder == null) { throw new IllegalArgumentException("inner bool query clause cannot be null"); } mustNotClauses.add(queryBuilder); return this; } /** * Gets the queries that must not appear in the matching documents. */ public List mustNot() { return this.mustNotClauses; } /** * Adds a clause that should be matched by the returned documents. For a boolean query with no * {@code MUST} clauses one or more SHOULD clauses must match a document * for the BooleanQuery to match. No {@code null} value allowed. * * @see #minimumShouldMatch(int) */ public BoolQueryBuilder should(QueryBuilder queryBuilder) { if (queryBuilder == null) { throw new IllegalArgumentException("inner bool query clause cannot be null"); } shouldClauses.add(queryBuilder); return this; } /** * Gets the list of clauses that should be matched by the returned documents. * * @see #should(QueryBuilder) * @see #minimumShouldMatch(int) */ public List should() { return this.shouldClauses; } /** * @return the string representation of the minimumShouldMatch settings for this query */ public String minimumShouldMatch() { return this.minimumShouldMatch; } /** * Sets the minimum should match parameter using the special syntax (for example, supporting percentage). * @see BoolQueryBuilder#minimumShouldMatch(int) */ public BoolQueryBuilder minimumShouldMatch(String minimumShouldMatch) { this.minimumShouldMatch = minimumShouldMatch; return this; } /** * Specifies a minimum number of the optional (should) boolean clauses which must be satisfied. *

* By default no optional clauses are necessary for a match * (unless there are no required clauses). If this method is used, * then the specified number of clauses is required. *

* Use of this method is totally independent of specifying that * any specific clauses are required (or prohibited). This number will * only be compared against the number of matching optional clauses. * * @param minimumShouldMatch the number of optional clauses that must match */ public BoolQueryBuilder minimumShouldMatch(int minimumShouldMatch) { this.minimumShouldMatch = Integer.toString(minimumShouldMatch); return this; } /** * Returns true iff this query builder has at least one should, must, must not or filter clause. * Otherwise false. */ public boolean hasClauses() { return !(mustClauses.isEmpty() && shouldClauses.isEmpty() && mustNotClauses.isEmpty() && filterClauses.isEmpty()); } /** * If a boolean query contains only negative ("must not") clauses should the * BooleanQuery be enhanced with a {@link MatchAllDocsQuery} in order to act * as a pure exclude. The default is true. */ public BoolQueryBuilder adjustPureNegative(boolean adjustPureNegative) { this.adjustPureNegative = adjustPureNegative; return this; } /** * @return the setting for the adjust_pure_negative setting in this query */ public boolean adjustPureNegative() { return this.adjustPureNegative; } @Override protected void doXContent(XContentBuilder builder, Params params) throws IOException { builder.startObject(NAME); doXArrayContent(MUST, mustClauses, builder, params); doXArrayContent(FILTER, filterClauses, builder, params); doXArrayContent(MUST_NOT, mustNotClauses, builder, params); doXArrayContent(SHOULD, shouldClauses, builder, params); builder.field(ADJUST_PURE_NEGATIVE.getPreferredName(), adjustPureNegative); if (minimumShouldMatch != null) { builder.field(MINIMUM_SHOULD_MATCH.getPreferredName(), minimumShouldMatch); } printBoostAndQueryName(builder); builder.endObject(); } private static void doXArrayContent(ParseField field, List clauses, XContentBuilder builder, Params params) throws IOException { if (clauses.isEmpty()) { return; } builder.startArray(field.getPreferredName()); for (QueryBuilder clause : clauses) { clause.toXContent(builder, params); } builder.endArray(); } private static final ObjectParser PARSER = new ObjectParser<>("bool", BoolQueryBuilder::new); static { PARSER.declareObjectArrayOrNull((builder, clauses) -> clauses.forEach(builder::must), (p, c) -> parseInnerQueryBuilder(p), MUST); PARSER.declareObjectArrayOrNull( (builder, clauses) -> clauses.forEach(builder::should), (p, c) -> parseInnerQueryBuilder(p), SHOULD ); PARSER.declareObjectArrayOrNull( (builder, clauses) -> clauses.forEach(builder::mustNot), (p, c) -> parseInnerQueryBuilder(p), MUST_NOT ); PARSER.declareObjectArrayOrNull( (builder, clauses) -> clauses.forEach(builder::filter), (p, c) -> parseInnerQueryBuilder(p), FILTER ); PARSER.declareBoolean(BoolQueryBuilder::adjustPureNegative, ADJUST_PURE_NEGATIVE); PARSER.declareField( BoolQueryBuilder::minimumShouldMatch, (p, c) -> p.textOrNull(), MINIMUM_SHOULD_MATCH, ObjectParser.ValueType.VALUE ); PARSER.declareString(BoolQueryBuilder::queryName, NAME_FIELD); PARSER.declareFloat(BoolQueryBuilder::boost, BOOST_FIELD); } public static BoolQueryBuilder fromXContent(XContentParser parser) throws IOException, ParsingException { return PARSER.parse(parser, null); } @Override public String getWriteableName() { return NAME; } @Override protected Query doToQuery(QueryShardContext context) throws IOException { BooleanQuery.Builder booleanQueryBuilder = new BooleanQuery.Builder(); addBooleanClauses(context, booleanQueryBuilder, mustClauses, BooleanClause.Occur.MUST); addBooleanClauses(context, booleanQueryBuilder, mustNotClauses, BooleanClause.Occur.MUST_NOT); addBooleanClauses(context, booleanQueryBuilder, shouldClauses, BooleanClause.Occur.SHOULD); addBooleanClauses(context, booleanQueryBuilder, filterClauses, BooleanClause.Occur.FILTER); BooleanQuery booleanQuery = booleanQueryBuilder.build(); if (booleanQuery.clauses().isEmpty()) { return new MatchAllDocsQuery(); } Query query = Queries.applyMinimumShouldMatch(booleanQuery, minimumShouldMatch); return adjustPureNegative ? fixNegativeQueryIfNeeded(query) : query; } private static void addBooleanClauses( QueryShardContext context, BooleanQuery.Builder booleanQueryBuilder, List clauses, Occur occurs ) throws IOException { for (QueryBuilder query : clauses) { Query luceneQuery = query.toQuery(context); booleanQueryBuilder.add(new BooleanClause(luceneQuery, occurs)); } } @Override protected int doHashCode() { return Objects.hash(adjustPureNegative, minimumShouldMatch, mustClauses, shouldClauses, mustNotClauses, filterClauses); } @Override protected boolean doEquals(BoolQueryBuilder other) { return Objects.equals(adjustPureNegative, other.adjustPureNegative) && Objects.equals(minimumShouldMatch, other.minimumShouldMatch) && Objects.equals(mustClauses, other.mustClauses) && Objects.equals(shouldClauses, other.shouldClauses) && Objects.equals(mustNotClauses, other.mustNotClauses) && Objects.equals(filterClauses, other.filterClauses); } @Override protected QueryBuilder doRewrite(QueryRewriteContext queryRewriteContext) throws IOException { BoolQueryBuilder newBuilder = new BoolQueryBuilder(); boolean changed = false; final int clauses = mustClauses.size() + mustNotClauses.size() + filterClauses.size() + shouldClauses.size(); if (clauses == 0) { return new MatchAllQueryBuilder().boost(boost()).queryName(queryName()); } changed |= rewriteClauses(queryRewriteContext, mustClauses, newBuilder::must); changed |= rewriteClauses(queryRewriteContext, mustNotClauses, newBuilder::mustNot); changed |= rewriteClauses(queryRewriteContext, filterClauses, newBuilder::filter); changed |= rewriteClauses(queryRewriteContext, shouldClauses, newBuilder::should); // early termination when must clause is empty and optional clauses is returning MatchNoneQueryBuilder if (mustClauses.size() == 0 && filterClauses.size() == 0 && shouldClauses.size() > 0 && newBuilder.shouldClauses.stream().allMatch(b -> b instanceof MatchNoneQueryBuilder)) { return new MatchNoneQueryBuilder(); } // lets do some early termination and prevent any kind of rewriting if we have a mandatory query that is a MatchNoneQueryBuilder Optional any = Stream.concat(newBuilder.mustClauses.stream(), newBuilder.filterClauses.stream()) .filter(b -> b instanceof MatchNoneQueryBuilder) .findAny(); if (any.isPresent()) { return any.get(); } if (changed) { newBuilder.adjustPureNegative = adjustPureNegative; newBuilder.minimumShouldMatch = minimumShouldMatch; newBuilder.boost(boost()); newBuilder.queryName(queryName()); return newBuilder; } return this; } @Override protected void extractInnerHitBuilders(Map innerHits) { List clauses = new ArrayList<>(filter()); clauses.addAll(must()); clauses.addAll(should()); // no need to include must_not (since there will be no hits for it) for (QueryBuilder clause : clauses) { InnerHitContextBuilder.extractInnerHits(clause, innerHits); } } private static boolean rewriteClauses( QueryRewriteContext queryRewriteContext, List builders, Consumer consumer ) throws IOException { boolean changed = false; for (QueryBuilder builder : builders) { QueryBuilder result = builder.rewrite(queryRewriteContext); if (result != builder) { changed = true; } consumer.accept(result); } return changed; } }