diff --git a/sandbox/plugins/dsl-query-executor/README.md b/sandbox/plugins/dsl-query-executor/README.md index 3b2bc297d0787..c8574bf2ea92c 100644 --- a/sandbox/plugins/dsl-query-executor/README.md +++ b/sandbox/plugins/dsl-query-executor/README.md @@ -19,6 +19,43 @@ _search request → SearchResponseBuilder (builds SearchResponse) ``` +### Bool Query +Converts to Calcite logical expressions with full support for all clauses and `minimum_should_match`. + +**Clauses:** +- `must` - Required clauses (AND logic) +- `should` - Optional clauses (OR logic) +- `must_not` - Exclusion clauses (NOT logic) +- `filter` - Filtering clauses (AND logic, no scoring) + +**minimum_should_match formats:** +- Non-negative integer: `"2"` - exactly 2 clauses must match +- Negative integer: `"-1"` - total minus 1 must match +- Non-negative percentage: `"70%"` - 70% of clauses (rounded down) +- Negative percentage: `"-30%"` - can miss 30% of clauses +- Single combination: `"2<75%"` - if total ≤ 2 match all, else 75% +- Multiple combinations: `"3<-1 5<50%"` - threshold-based rules + +**Example:** +```json +{ + "bool": { + "must": [ + {"term": {"status": "active"}} + ], + "should": [ + {"term": {"priority": "high"}}, + {"term": {"priority": "medium"}}, + {"term": {"priority": "low"}} + ], + "must_not": [ + {"term": {"deleted": "true"}} + ], + "minimum_should_match": "2" + } +} +``` + ## Dependencies - `analytics-engine` — provides `QueryPlanExecutor` and `EngineContext` via Guice (declared as `extendedPlugins`) @@ -42,8 +79,11 @@ _search request ```bash # Unit tests -./gradlew :sandbox:plugins:dsl-query-executor:test +./gradlew :sandbox:plugins:dsl-query-executor:test -Dsandbox.enabled=true # Integration tests -./gradlew :sandbox:plugins:dsl-query-executor:internalClusterTest +./gradlew :sandbox:plugins:dsl-query-executor:internalClusterTest -Dsandbox.enabled=true + +# Specific test class +./gradlew :sandbox:plugins:dsl-query-executor:test --tests "BoolQueryTranslatorTests" -Dsandbox.enabled=true ``` diff --git a/sandbox/plugins/dsl-query-executor/src/internalClusterTest/java/org/opensearch/dsl/DslBoolQueryIT.java b/sandbox/plugins/dsl-query-executor/src/internalClusterTest/java/org/opensearch/dsl/DslBoolQueryIT.java new file mode 100644 index 0000000000000..b99b98e310118 --- /dev/null +++ b/sandbox/plugins/dsl-query-executor/src/internalClusterTest/java/org/opensearch/dsl/DslBoolQueryIT.java @@ -0,0 +1,308 @@ +/* + * 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. + */ + +package org.opensearch.dsl; + +import org.apache.lucene.tests.util.LuceneTestCase.AwaitsFix; +import org.opensearch.common.xcontent.XContentType; +import org.opensearch.index.query.QueryBuilders; +import org.opensearch.search.builder.SearchSourceBuilder; + +/** + * Integration tests for bool query with minimum_should_match parameter. + */ +@AwaitsFix(bugUrl = "DSL query pipeline not fully wired E2E: fragment conversion, shard execution and Arrow Flight drain pending") +public class DslBoolQueryIT extends DslIntegTestBase { + + @Override + protected void createTestIndex() { + createIndex(INDEX); + ensureGreen(); + + // Index multiple documents with different field values + client().prepareIndex(INDEX) + .setId("1") + .setSource( + "{\"name\":\"laptop\",\"price\":1200,\"brand\":\"brandX\",\"rating\":4.5,\"tag\":\"electronics\"}", + XContentType.JSON + ) + .get(); + client().prepareIndex(INDEX) + .setId("2") + .setSource("{\"name\":\"phone\",\"price\":800,\"brand\":\"brandY\",\"rating\":4.0,\"tag\":\"electronics\"}", XContentType.JSON) + .get(); + client().prepareIndex(INDEX) + .setId("3") + .setSource("{\"name\":\"tablet\",\"price\":600,\"brand\":\"brandX\",\"rating\":3.5,\"tag\":\"electronics\"}", XContentType.JSON) + .get(); + client().prepareIndex(INDEX) + .setId("4") + .setSource( + "{\"name\":\"monitor\",\"price\":400,\"brand\":\"brandZ\",\"rating\":4.2,\"tag\":\"accessories\"}", + XContentType.JSON + ) + .get(); + refresh(INDEX); + } + + // Basic bool query tests + + public void testBoolQueryWithMust() { + createTestIndex(); + assertOk(search(new SearchSourceBuilder().query(QueryBuilders.boolQuery().must(QueryBuilders.termQuery("tag", "electronics"))))); + } + + public void testBoolQueryWithShould() { + createTestIndex(); + assertOk( + search( + new SearchSourceBuilder().query( + QueryBuilders.boolQuery() + .should(QueryBuilders.termQuery("brand", "brandX")) + .should(QueryBuilders.termQuery("brand", "brandY")) + ) + ) + ); + } + + public void testBoolQueryWithMustNot() { + createTestIndex(); + assertOk( + search( + new SearchSourceBuilder().query( + QueryBuilders.boolQuery() + .must(QueryBuilders.termQuery("tag", "electronics")) + .mustNot(QueryBuilders.termQuery("brand", "brandZ")) + ) + ) + ); + } + + public void testBoolQueryWithFilter() { + createTestIndex(); + assertOk(search(new SearchSourceBuilder().query(QueryBuilders.boolQuery().filter(QueryBuilders.termQuery("tag", "electronics"))))); + } + + public void testBoolQueryWithAllClauses() { + createTestIndex(); + assertOk( + search( + new SearchSourceBuilder().query( + QueryBuilders.boolQuery() + .must(QueryBuilders.termQuery("tag", "electronics")) + .should(QueryBuilders.termQuery("brand", "brandX")) + .mustNot(QueryBuilders.termQuery("name", "phone")) + .filter(QueryBuilders.termQuery("rating", 4.5)) + ) + ) + ); + } + + // minimum_should_match: Integer tests + + public void testMinimumShouldMatchPositiveInteger() { + createTestIndex(); + assertOk( + search( + new SearchSourceBuilder().query( + QueryBuilders.boolQuery() + .should(QueryBuilders.termQuery("brand", "brandX")) + .should(QueryBuilders.termQuery("brand", "brandY")) + .should(QueryBuilders.termQuery("brand", "brandZ")) + .minimumShouldMatch("2") + ) + ) + ); + } + + public void testMinimumShouldMatchNegativeInteger() { + createTestIndex(); + assertOk( + search( + new SearchSourceBuilder().query( + QueryBuilders.boolQuery() + .should(QueryBuilders.termQuery("brand", "brandX")) + .should(QueryBuilders.termQuery("brand", "brandY")) + .should(QueryBuilders.termQuery("brand", "brandZ")) + .minimumShouldMatch("-1") + ) + ) + ); + } + + // minimum_should_match: Percentage tests + + public void testMinimumShouldMatchPositivePercentage() { + createTestIndex(); + assertOk( + search( + new SearchSourceBuilder().query( + QueryBuilders.boolQuery() + .should(QueryBuilders.termQuery("tag", "electronics")) + .should(QueryBuilders.termQuery("brand", "brandX")) + .should(QueryBuilders.termQuery("name", "laptop")) + .should(QueryBuilders.termQuery("rating", 4.5)) + .minimumShouldMatch("75%") + ) + ) + ); + } + + public void testMinimumShouldMatchNegativePercentage() { + createTestIndex(); + assertOk( + search( + new SearchSourceBuilder().query( + QueryBuilders.boolQuery() + .should(QueryBuilders.termQuery("tag", "electronics")) + .should(QueryBuilders.termQuery("brand", "brandX")) + .should(QueryBuilders.termQuery("name", "laptop")) + .should(QueryBuilders.termQuery("rating", 4.5)) + .minimumShouldMatch("-25%") + ) + ) + ); + } + + // minimum_should_match: Combination tests + + public void testMinimumShouldMatchSingleCombination() { + createTestIndex(); + assertOk( + search( + new SearchSourceBuilder().query( + QueryBuilders.boolQuery() + .should(QueryBuilders.termQuery("brand", "brandX")) + .should(QueryBuilders.termQuery("brand", "brandY")) + .minimumShouldMatch("2<75%") + ) + ) + ); + } + + public void testMinimumShouldMatchMultipleCombinations() { + createTestIndex(); + assertOk( + search( + new SearchSourceBuilder().query( + QueryBuilders.boolQuery() + .should(QueryBuilders.termQuery("tag", "electronics")) + .should(QueryBuilders.termQuery("brand", "brandX")) + .should(QueryBuilders.termQuery("name", "laptop")) + .should(QueryBuilders.termQuery("rating", 4.5)) + .minimumShouldMatch("3<-1 5<50%") + ) + ) + ); + } + + // minimum_should_match with must/filter + + public void testMinimumShouldMatchWithMust() { + createTestIndex(); + assertOk( + search( + new SearchSourceBuilder().query( + QueryBuilders.boolQuery() + .must(QueryBuilders.termQuery("tag", "electronics")) + .should(QueryBuilders.termQuery("brand", "brandX")) + .should(QueryBuilders.termQuery("brand", "brandY")) + .minimumShouldMatch("1") + ) + ) + ); + } + + public void testMinimumShouldMatchWithFilter() { + createTestIndex(); + assertOk( + search( + new SearchSourceBuilder().query( + QueryBuilders.boolQuery() + .filter(QueryBuilders.termQuery("tag", "electronics")) + .should(QueryBuilders.termQuery("brand", "brandX")) + .should(QueryBuilders.termQuery("brand", "brandY")) + .minimumShouldMatch("1") + ) + ) + ); + } + + // Nested bool queries + + public void testNestedBoolQuery() { + createTestIndex(); + assertOk( + search( + new SearchSourceBuilder().query( + QueryBuilders.boolQuery() + .must(QueryBuilders.termQuery("tag", "electronics")) + .must( + QueryBuilders.boolQuery() + .should(QueryBuilders.termQuery("brand", "brandX")) + .should(QueryBuilders.termQuery("brand", "brandY")) + ) + ) + ) + ); + } + + public void testNestedBoolQueryWithMinimumShouldMatch() { + createTestIndex(); + assertOk( + search( + new SearchSourceBuilder().query( + QueryBuilders.boolQuery() + .must(QueryBuilders.termQuery("tag", "electronics")) + .must( + QueryBuilders.boolQuery() + .should(QueryBuilders.termQuery("brand", "brandX")) + .should(QueryBuilders.termQuery("brand", "brandY")) + .should(QueryBuilders.termQuery("brand", "brandZ")) + .minimumShouldMatch("2") + ) + ) + ) + ); + } + + // Edge cases + + public void testBoolQueryWithOnlyShouldClause() { + createTestIndex(); + assertOk(search(new SearchSourceBuilder().query(QueryBuilders.boolQuery().should(QueryBuilders.termQuery("brand", "brandX"))))); + } + + public void testBoolQueryWithMinimumShouldMatchOne() { + createTestIndex(); + assertOk( + search( + new SearchSourceBuilder().query( + QueryBuilders.boolQuery() + .should(QueryBuilders.termQuery("brand", "brandX")) + .should(QueryBuilders.termQuery("brand", "brandY")) + .minimumShouldMatch("1") + ) + ) + ); + } + + public void testBoolQueryWithMinimumShouldMatchAll() { + createTestIndex(); + assertOk( + search( + new SearchSourceBuilder().query( + QueryBuilders.boolQuery() + .should(QueryBuilders.termQuery("tag", "electronics")) + .should(QueryBuilders.termQuery("brand", "brandX")) + .minimumShouldMatch("2") + ) + ) + ); + } +} diff --git a/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/query/BoolQueryTranslator.java b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/query/BoolQueryTranslator.java new file mode 100644 index 0000000000000..b8068197bc77b --- /dev/null +++ b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/query/BoolQueryTranslator.java @@ -0,0 +1,241 @@ +/* + * 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. + */ + +package org.opensearch.dsl.query; + +import org.apache.calcite.rex.RexCall; +import org.apache.calcite.rex.RexNode; +import org.apache.calcite.sql.fun.SqlStdOperatorTable; +import org.apache.calcite.sql.type.SqlTypeName; +import org.opensearch.dsl.converter.ConversionContext; +import org.opensearch.dsl.converter.ConversionException; +import org.opensearch.index.query.AbstractQueryBuilder; +import org.opensearch.index.query.BoolQueryBuilder; +import org.opensearch.index.query.QueryBuilder; + +import java.util.ArrayList; +import java.util.List; + +/** + * Converts a {@link BoolQueryBuilder} to Calcite logical expressions (RexNode). + * + *
Handles must (AND), filter (AND), should (OR with minimum_should_match), and + * must_not (NOT with double-negation elimination). Flattens nested AND/OR to satisfy + * Calcite's RexUtil.isFlat requirement. + * + *
For minimum_should_match with 1 less-than k less-than n, emits the conjoined form + * AND(OR(p1..pn), GTE(left-deep PLUS chain of CASE(pi, 1, 0), k)) which provides + * linear expression size and preserves page pruning via the OR conjunct. + * + *
Rejects non-default boost (AbstractQueryBuilder.toQuery wraps in BoostQuery),
+ * non-null _name (AbstractQueryBuilder.toQuery registers for matched_queries), and
+ * returns FALSE literal for pure-negative bools with adjust_pure_negative=false (legacy match-none).
+ */
+public class BoolQueryTranslator implements QueryTranslator {
+
+ private final QueryRegistry queryRegistry;
+
+ /** Creates a new bool query translator with the given registry for recursive conversion. */
+ public BoolQueryTranslator(QueryRegistry queryRegistry) {
+ this.queryRegistry = queryRegistry;
+ }
+
+ @Override
+ public Class extends QueryBuilder> getQueryType() {
+ return BoolQueryBuilder.class;
+ }
+
+ /**
+ * Converts a bool query to a Calcite RexNode.
+ *
+ * @throws ConversionException if boost or _name is non-default, or if nested conversion fails
+ */
+ @Override
+ public RexNode convert(QueryBuilder query, ConversionContext ctx) throws ConversionException {
+ BoolQueryBuilder boolQuery = (BoolQueryBuilder) query;
+
+ // Parameter audit: reject unsupported parameters matching ExistsQueryTranslator/TermsQueryTranslator style.
+ // Citation: AbstractQueryBuilder.toQuery lines 130-139 (boost wrapping + named query registration).
+ if (boolQuery.boost() != AbstractQueryBuilder.DEFAULT_BOOST) {
+ throw new ConversionException("Bool query does not support non-default boost");
+ }
+ if (boolQuery.queryName() != null) {
+ throw new ConversionException("Bool query does not support _name");
+ }
+ // WHY shape-gated: BoolQueryBuilder.doToQuery:338 only calls fixNegativeQueryIfNeeded when
+ // adjustPureNegative is true. Queries.isNegativeQuery:113-119 requires every clause to be
+ // prohibited, so the flag is a no-op unless the bool is pure-negative (must, filter, should
+ // all empty). Queries.fixNegativeQueryIfNeeded:121-130 injects MatchAllDocsQuery as FILTER.
+ boolean isPureNegative = boolQuery.must().isEmpty()
+ && boolQuery.filter().isEmpty()
+ && boolQuery.should().isEmpty()
+ && !boolQuery.mustNot().isEmpty();
+ if (isPureNegative && !boolQuery.adjustPureNegative()) {
+ return ctx.getRexBuilder().makeLiteral(false);
+ }
+
+ List The OR conjunct is logically redundant (implied by GTE when k is at least 1) but exists
+ * so the analytics backend page pruner sees column-vs-constant comparison leaves. AND
+ * intersects per-child pruning bitmaps, so the opaque counting sibling contributes all-true
+ * and does not disable the OR child's pruning.
+ */
+ private RexNode createMinimumMatchCondition(List Semantics match legacy {@code org.opensearch.common.lucene.search.Queries.calculateMinShouldMatch}.
+ */
+final class MinimumShouldMatchParser {
+
+ private MinimumShouldMatchParser() {}
+
+ /**
+ * Calculates the required number of should clauses that must match.
+ *
+ * Unlike legacy, this does NOT clamp the upper bound. Values exceeding
+ * totalShould are passed through; the caller handles the match-none case.
+ * Floor clamp to 0 matches legacy Queries.calculateMinShouldMatch line 207.
+ *
+ * @return number of should clauses that must match (clamped floor to 0 only)
+ */
+ static int calculateRequiredMatches(String minimumShouldMatch, int totalShould, boolean hasRequired) throws ConversionException {
+ if (minimumShouldMatch == null || minimumShouldMatch.isEmpty()) {
+ return hasRequired ? 0 : 1;
+ }
+
+ int result;
+
+ if (minimumShouldMatch.contains(" ")) {
+ result = parseMultipleCombinations(minimumShouldMatch, totalShould);
+ } else if (minimumShouldMatch.contains("<")) {
+ result = parseCombination(minimumShouldMatch, totalShould);
+ } else if (minimumShouldMatch.endsWith("%")) {
+ result = parsePercentage(minimumShouldMatch, totalShould);
+ } else {
+ result = parseInteger(minimumShouldMatch, totalShould);
+ }
+
+ // Floor clamp only — matches legacy Queries.calculateMinShouldMatch line 207: "return result < 0 ? 0 : result"
+ return Math.max(0, result);
+ }
+
+ /**
+ * Parses an integer minimum_should_match value.
+ * Non-negative values are returned as-is. Negative values are subtracted from total.
+ */
+ private static int parseInteger(String value, int total) throws ConversionException {
+ try {
+ int num = Integer.parseInt(value);
+ return num >= 0 ? num : total + num;
+ } catch (NumberFormatException e) {
+ throw new ConversionException("Invalid integer in minimum_should_match: \"" + value + "\"", e);
+ }
+ }
+
+ /**
+ * Parses a percentage minimum_should_match value.
+ * Non-negative percentages are applied directly. Negative percentages represent allowed misses.
+ *
+ * WHY Integer.parseInt: legacy Queries.calculateMinShouldMatch (line ~199) uses
+ * {@code Integer.parseInt(spec)} for the percentage numeric part, rejecting non-integer
+ * and non-finite values (NaN, Infinity, fractional). We match that behavior exactly.
+ */
+ private static int parsePercentage(String value, int total) throws ConversionException {
+ String numStr = value.substring(0, value.length() - 1);
+ try {
+ int percent = Integer.parseInt(numStr);
+ float calc = (total * percent) * (1 / 100f);
+ return percent >= 0 ? (int) calc : total + (int) calc;
+ } catch (NumberFormatException e) {
+ throw new ConversionException("Invalid percentage in minimum_should_match: \"" + value + "\"", e);
+ }
+ }
+
+ /**
+ * Parses a single combination minimum_should_match value (e.g., "2<75%").
+ * If total is less than or equal to threshold, all clauses must match.
+ */
+ private static int parseCombination(String value, int total) throws ConversionException {
+ String[] parts = value.split("<");
+ if (parts.length < 2 || parts[1].isEmpty()) {
+ throw new ConversionException("Malformed combination in minimum_should_match: \"" + value + "\"");
+ }
+ int threshold;
+ try {
+ threshold = Integer.parseInt(parts[0]);
+ } catch (NumberFormatException e) {
+ throw new ConversionException("Invalid threshold in minimum_should_match: \"" + parts[0] + "\"", e);
+ }
+ if (total <= threshold) {
+ return total;
+ }
+ return parts[1].endsWith("%") ? parsePercentage(parts[1], total) : parseInteger(parts[1], total);
+ }
+
+ /**
+ * Parses multiple combinations minimum_should_match value (e.g., "3<-1 5<50%").
+ * Applies the appropriate rule based on which threshold range the total falls into.
+ */
+ private static int parseMultipleCombinations(String value, int total) throws ConversionException {
+ String[] combinations = value.trim().split("\\s+");
+ int result = total;
+ for (String combination : combinations) {
+ String[] parts = combination.split("<");
+ if (parts.length < 2 || parts[1].isEmpty()) {
+ throw new ConversionException("Malformed combination in minimum_should_match: \"" + combination + "\"");
+ }
+ int threshold;
+ try {
+ threshold = Integer.parseInt(parts[0]);
+ } catch (NumberFormatException e) {
+ throw new ConversionException("Invalid threshold in minimum_should_match: \"" + parts[0] + "\"", e);
+ }
+ if (total <= threshold) {
+ return result;
+ }
+ result = parts[1].endsWith("%") ? parsePercentage(parts[1], total) : parseInteger(parts[1], total);
+ }
+ return result;
+ }
+}
diff --git a/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/query/QueryRegistryFactory.java b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/query/QueryRegistryFactory.java
index f0bc550d59782..9dc25d49fe955 100644
--- a/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/query/QueryRegistryFactory.java
+++ b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/query/QueryRegistryFactory.java
@@ -22,6 +22,7 @@ public static QueryRegistry create() {
registry.register(new TermsQueryTranslator());
registry.register(new MatchAllQueryTranslator());
registry.register(new ExistsQueryTranslator());
+ registry.register(new BoolQueryTranslator(registry));
// TODO: add other query translators
return registry;
}
diff --git a/sandbox/plugins/dsl-query-executor/src/test/java/org/opensearch/dsl/query/BoolQueryTranslatorTests.java b/sandbox/plugins/dsl-query-executor/src/test/java/org/opensearch/dsl/query/BoolQueryTranslatorTests.java
new file mode 100644
index 0000000000000..3e543bedffe6d
--- /dev/null
+++ b/sandbox/plugins/dsl-query-executor/src/test/java/org/opensearch/dsl/query/BoolQueryTranslatorTests.java
@@ -0,0 +1,876 @@
+/*
+ * 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.
+ */
+
+package org.opensearch.dsl.query;
+
+import org.apache.calcite.rex.RexCall;
+import org.apache.calcite.rex.RexLiteral;
+import org.apache.calcite.rex.RexNode;
+import org.apache.calcite.rex.RexUtil;
+import org.apache.calcite.sql.SqlKind;
+import org.apache.calcite.sql.type.SqlTypeName;
+import org.opensearch.dsl.TestUtils;
+import org.opensearch.dsl.converter.ConversionContext;
+import org.opensearch.dsl.converter.ConversionException;
+import org.opensearch.index.query.BoolQueryBuilder;
+import org.opensearch.index.query.QueryBuilders;
+import org.opensearch.test.OpenSearchTestCase;
+
+public class BoolQueryTranslatorTests extends OpenSearchTestCase {
+
+ private final QueryRegistry registry = QueryRegistryFactory.create();
+ private final BoolQueryTranslator translator = new BoolQueryTranslator(registry);
+ private final ConversionContext ctx = TestUtils.createContext();
+
+ // Basic bool query tests
+
+ public void testMustClause() throws ConversionException {
+ RexNode result = translator.convert(QueryBuilders.boolQuery().must(QueryBuilders.termQuery("name", "test")), ctx);
+
+ assertTrue(result instanceof RexCall);
+ RexCall call = (RexCall) result;
+ assertEquals(SqlKind.EQUALS, call.getKind());
+ }
+
+ public void testShouldClauseWithoutMust() throws ConversionException {
+ RexNode result = translator.convert(
+ QueryBuilders.boolQuery().should(QueryBuilders.termQuery("name", "test1")).should(QueryBuilders.termQuery("name", "test2")),
+ ctx
+ );
+
+ assertTrue(result instanceof RexCall);
+ RexCall call = (RexCall) result;
+ assertEquals(SqlKind.OR, call.getKind());
+ assertEquals(2, call.getOperands().size());
+ }
+
+ public void testMustNotClause() throws ConversionException {
+ RexNode result = translator.convert(QueryBuilders.boolQuery().mustNot(QueryBuilders.termQuery("name", "test")), ctx);
+
+ assertTrue(result instanceof RexCall);
+ RexCall call = (RexCall) result;
+ assertEquals(SqlKind.NOT, call.getKind());
+ }
+
+ // minimum_should_match: Non-negative integer
+
+ public void testMinimumShouldMatchInteger2() throws ConversionException {
+ RexNode result = translator.convert(
+ QueryBuilders.boolQuery()
+ .should(QueryBuilders.termQuery("name", "a"))
+ .should(QueryBuilders.termQuery("name", "b"))
+ .should(QueryBuilders.termQuery("name", "c"))
+ .minimumShouldMatch("2"),
+ ctx
+ );
+
+ assertTrue(result instanceof RexCall);
+ RexCall call = (RexCall) result;
+ assertEquals(SqlKind.AND, call.getKind());
+ // Conjoined form: AND(OR(a, b, c), GTE(PLUS(CASE...), 2))
+ assertEquals(2, call.getOperands().size());
+ assertEquals(SqlKind.OR, ((RexCall) call.getOperands().get(0)).getKind());
+ assertEquals(SqlKind.GREATER_THAN_OR_EQUAL, ((RexCall) call.getOperands().get(1)).getKind());
+ }
+
+ public void testMinimumShouldMatchInteger1() throws ConversionException {
+ RexNode result = translator.convert(
+ QueryBuilders.boolQuery()
+ .should(QueryBuilders.termQuery("name", "a"))
+ .should(QueryBuilders.termQuery("name", "b"))
+ .minimumShouldMatch("1"),
+ ctx
+ );
+
+ assertTrue(result instanceof RexCall);
+ RexCall call = (RexCall) result;
+ assertEquals(SqlKind.OR, call.getKind());
+ assertEquals(2, call.getOperands().size());
+ }
+
+ // minimum_should_match: Negative integer
+
+ public void testMinimumShouldMatchNegativeInteger() throws ConversionException {
+ RexNode result = translator.convert(
+ QueryBuilders.boolQuery()
+ .should(QueryBuilders.termQuery("name", "a"))
+ .should(QueryBuilders.termQuery("name", "b"))
+ .should(QueryBuilders.termQuery("name", "c"))
+ .minimumShouldMatch("-1"),
+ ctx
+ );
+
+ assertTrue(result instanceof RexCall);
+ RexCall call = (RexCall) result;
+ // -1 means total - 1 = 3 - 1 = 2 required → conjoined form
+ assertEquals(SqlKind.AND, call.getKind());
+ assertEquals(2, call.getOperands().size());
+ assertEquals(SqlKind.OR, ((RexCall) call.getOperands().get(0)).getKind());
+ assertEquals(3, ((RexCall) call.getOperands().get(0)).getOperands().size());
+ }
+
+ // minimum_should_match: Non-negative percentage
+
+ public void testMinimumShouldMatchPercentage70() throws ConversionException {
+ RexNode result = translator.convert(
+ QueryBuilders.boolQuery()
+ .should(QueryBuilders.termQuery("name", "a"))
+ .should(QueryBuilders.termQuery("name", "b"))
+ .should(QueryBuilders.termQuery("name", "c"))
+ .should(QueryBuilders.termQuery("name", "d"))
+ .minimumShouldMatch("70%"),
+ ctx
+ );
+
+ assertTrue(result instanceof RexCall);
+ RexCall call = (RexCall) result;
+ // 70% of 4 = 2.8, floor = 2 required → conjoined form AND(OR, GTE)
+ assertEquals(SqlKind.AND, call.getKind());
+ assertEquals(2, call.getOperands().size());
+ }
+
+ public void testMinimumShouldMatchPercentage50() throws ConversionException {
+ RexNode result = translator.convert(
+ QueryBuilders.boolQuery()
+ .should(QueryBuilders.termQuery("name", "a"))
+ .should(QueryBuilders.termQuery("name", "b"))
+ .should(QueryBuilders.termQuery("name", "c"))
+ .should(QueryBuilders.termQuery("name", "d"))
+ .minimumShouldMatch("50%"),
+ ctx
+ );
+
+ assertTrue(result instanceof RexCall);
+ RexCall call = (RexCall) result;
+ // 50% of 4 = 2 required → conjoined form AND(OR, GTE)
+ assertEquals(SqlKind.AND, call.getKind());
+ assertEquals(2, call.getOperands().size());
+ }
+
+ // minimum_should_match: Negative percentage
+
+ public void testMinimumShouldMatchNegativePercentage() throws ConversionException {
+ RexNode result = translator.convert(
+ QueryBuilders.boolQuery()
+ .should(QueryBuilders.termQuery("name", "a"))
+ .should(QueryBuilders.termQuery("name", "b"))
+ .should(QueryBuilders.termQuery("name", "c"))
+ .should(QueryBuilders.termQuery("name", "d"))
+ .minimumShouldMatch("-30%"),
+ ctx
+ );
+
+ assertTrue(result instanceof RexCall);
+ RexCall call = (RexCall) result;
+ // -30% means can miss 30% = 1.2, floor = 1, so 4 - 1 = 3 required → conjoined form
+ assertEquals(SqlKind.AND, call.getKind());
+ assertEquals(2, call.getOperands().size());
+ }
+
+ // minimum_should_match: Single combination
+
+ public void testMinimumShouldMatchCombination2Less75Percent() throws ConversionException {
+ // 2<75% means: if total <= 2, match all; if total > 2, match 75%
+ RexNode result = translator.convert(
+ QueryBuilders.boolQuery()
+ .should(QueryBuilders.termQuery("name", "a"))
+ .should(QueryBuilders.termQuery("name", "b"))
+ .minimumShouldMatch("2<75%"),
+ ctx
+ );
+
+ assertTrue(result instanceof RexCall);
+ RexCall call = (RexCall) result;
+ // total = 2, so should match all (2)
+ assertEquals(SqlKind.AND, call.getKind());
+ }
+
+ public void testMinimumShouldMatchCombinationWithMoreClauses() throws ConversionException {
+ RexNode result = translator.convert(
+ QueryBuilders.boolQuery()
+ .should(QueryBuilders.termQuery("name", "a"))
+ .should(QueryBuilders.termQuery("name", "b"))
+ .should(QueryBuilders.termQuery("name", "c"))
+ .should(QueryBuilders.termQuery("name", "d"))
+ .minimumShouldMatch("2<75%"),
+ ctx
+ );
+
+ assertTrue(result instanceof RexCall);
+ RexCall call = (RexCall) result;
+ // total = 4 > 2, so 75% of 4 = 3 required → conjoined form
+ assertEquals(SqlKind.AND, call.getKind());
+ assertEquals(2, call.getOperands().size());
+ }
+
+ // minimum_should_match: Multiple combinations
+
+ public void testMinimumShouldMatchMultipleCombinations() throws ConversionException {
+ // 3<-1 5<50% means:
+ // if total <= 3: match all
+ // if 3 < total <= 5: match all but 1
+ // if total > 5: match 50%
+ RexNode result = translator.convert(
+ QueryBuilders.boolQuery()
+ .should(QueryBuilders.termQuery("name", "a"))
+ .should(QueryBuilders.termQuery("name", "b"))
+ .should(QueryBuilders.termQuery("name", "c"))
+ .should(QueryBuilders.termQuery("name", "d"))
+ .minimumShouldMatch("3<-1 5<50%"),
+ ctx
+ );
+
+ assertTrue(result instanceof RexCall);
+ RexCall call = (RexCall) result;
+ // total = 4, which is 3 < 4 <= 5, so -1 = 4 - 1 = 3 required → conjoined form
+ assertEquals(SqlKind.AND, call.getKind());
+ assertEquals(2, call.getOperands().size());
+ }
+
+ public void testMinimumShouldMatchMultipleCombinationsWithSixClauses() throws ConversionException {
+ RexNode result = translator.convert(
+ QueryBuilders.boolQuery()
+ .should(QueryBuilders.termQuery("name", "a"))
+ .should(QueryBuilders.termQuery("name", "b"))
+ .should(QueryBuilders.termQuery("name", "c"))
+ .should(QueryBuilders.termQuery("name", "d"))
+ .should(QueryBuilders.termQuery("name", "e"))
+ .should(QueryBuilders.termQuery("name", "f"))
+ .minimumShouldMatch("3<-1 5<50%"),
+ ctx
+ );
+
+ assertTrue(result instanceof RexCall);
+ RexCall call = (RexCall) result;
+ // total = 6 > 5, so 50% of 6 = 3 required → conjoined form
+ assertEquals(SqlKind.AND, call.getKind());
+ assertEquals(2, call.getOperands().size());
+ }
+
+ // Edge cases
+
+ public void testShouldWithMustClause() throws ConversionException {
+ RexNode result = translator.convert(
+ QueryBuilders.boolQuery()
+ .must(QueryBuilders.termQuery("name", "required"))
+ .should(QueryBuilders.termQuery("name", "a"))
+ .should(QueryBuilders.termQuery("name", "b")),
+ ctx
+ );
+
+ assertTrue(result instanceof RexCall);
+ RexCall call = (RexCall) result;
+ // Should clauses are optional when must is present (no minimum_should_match)
+ assertEquals(SqlKind.EQUALS, call.getKind());
+ }
+
+ public void testShouldWithMustAndMinimumShouldMatch() throws ConversionException {
+ RexNode result = translator.convert(
+ QueryBuilders.boolQuery()
+ .must(QueryBuilders.termQuery("name", "required"))
+ .should(QueryBuilders.termQuery("name", "a"))
+ .should(QueryBuilders.termQuery("name", "b"))
+ .minimumShouldMatch("1"),
+ ctx
+ );
+
+ assertTrue(result instanceof RexCall);
+ RexCall call = (RexCall) result;
+ assertEquals(SqlKind.AND, call.getKind());
+ assertEquals(2, call.getOperands().size());
+ }
+
+ public void testComplexBoolQuery() throws ConversionException {
+ RexNode result = translator.convert(
+ QueryBuilders.boolQuery()
+ .must(QueryBuilders.termQuery("name", "active"))
+ .should(QueryBuilders.termQuery("brand", "high"))
+ .should(QueryBuilders.termQuery("brand", "medium"))
+ .mustNot(QueryBuilders.termQuery("name", "deleted"))
+ .minimumShouldMatch("1"),
+ ctx
+ );
+
+ assertTrue(result instanceof RexCall);
+ RexCall call = (RexCall) result;
+ assertEquals(SqlKind.AND, call.getKind());
+ assertEquals(3, call.getOperands().size());
+ }
+
+ public void testReportsCorrectQueryType() {
+ assertEquals(BoolQueryBuilder.class, translator.getQueryType());
+ }
+
+ public void testNestedBoolQueryFlattening() throws ConversionException {
+ // Nested bool: bool(must: [term1, bool(must: [term2, term3])])
+ // Should flatten to: AND(term1, term2, term3)
+ RexNode result = translator.convert(
+ QueryBuilders.boolQuery()
+ .must(QueryBuilders.termQuery("name", "value1"))
+ .must(
+ QueryBuilders.boolQuery().must(QueryBuilders.termQuery("brand", "value2")).must(QueryBuilders.termQuery("rating", 3.0))
+ ),
+ ctx
+ );
+
+ assertTrue(result instanceof RexCall);
+ RexCall call = (RexCall) result;
+ assertEquals(SqlKind.AND, call.getKind());
+ // Should be flattened to 3 operands, not nested
+ assertEquals(3, call.getOperands().size());
+ }
+
+ public void testNestedShouldQueryFlattening() throws ConversionException {
+ // Nested should: bool(should: [term1, bool(should: [term2, term3])])
+ // Should flatten to: OR(term1, term2, term3)
+ RexNode result = translator.convert(
+ QueryBuilders.boolQuery()
+ .should(QueryBuilders.termQuery("name", "value1"))
+ .should(
+ QueryBuilders.boolQuery()
+ .should(QueryBuilders.termQuery("brand", "value2"))
+ .should(QueryBuilders.termQuery("rating", 3.0))
+ ),
+ ctx
+ );
+
+ assertTrue(result instanceof RexCall);
+ RexCall call = (RexCall) result;
+ assertEquals(SqlKind.OR, call.getKind());
+ // Should be flattened to 3 operands, not nested
+ assertEquals(3, call.getOperands().size());
+ }
+
+ public void testDoubleNegationElimination() throws ConversionException {
+ // bool(must_not: [bool(must_not: [term])])
+ // Should eliminate double negation: NOT(NOT(term)) -> term
+ RexNode result = translator.convert(
+ QueryBuilders.boolQuery().mustNot(QueryBuilders.boolQuery().mustNot(QueryBuilders.termQuery("name", "value"))),
+ ctx
+ );
+
+ assertTrue(result instanceof RexCall);
+ RexCall call = (RexCall) result;
+ // Should be the term itself, not wrapped in NOT
+ assertEquals(SqlKind.EQUALS, call.getKind());
+ }
+
+ // --- MSM result clamping tests ---
+
+ public void testCalculateRequiredMatchesClampedToZeroWhenNegativeResult() throws ConversionException {
+ // "-10" with 3 clauses → 3 + (-10) = -7 → must clamp to 0
+ int result = MinimumShouldMatchParser.calculateRequiredMatches("-10", 3, false);
+ assertEquals("Negative MSM result must be clamped to 0", 0, result);
+ }
+
+ public void testCalculateRequiredMatchesClampedToTotalWhenExceedsTotal() throws ConversionException {
+ // "5" with 3 clauses → 5 > 3 → legacy matches nothing (witnessed:
+ // BoolQueryBuilderTests.testMinShouldMatchBiggerThanNumberOfShouldClauses).
+ // calculateRequiredMatches returns the raw value; convert() handles the match-none.
+ int result = MinimumShouldMatchParser.calculateRequiredMatches("5", 3, false);
+ assertEquals("MSM exceeding totalShould must be returned as-is (> totalShould signals match-none)", 5, result);
+ }
+
+ public void testCalculateRequiredMatchesNotClampedWhenWithinRange() throws ConversionException {
+ // "2" with 3 clauses → within [0, 3], no clamping
+ int result = MinimumShouldMatchParser.calculateRequiredMatches("2", 3, false);
+ assertEquals("MSM within valid range should not be clamped", 2, result);
+ }
+
+ public void testCalculateRequiredMatchesClampedToTotalProducesAnd() throws ConversionException {
+ // When MSM exceeds totalShould, legacy matches nothing → produce FALSE literal.
+ // Witnessed: BoolQueryBuilderTests.testMinShouldMatchBiggerThanNumberOfShouldClauses.
+ RexNode result = translator.convert(
+ QueryBuilders.boolQuery()
+ .should(QueryBuilders.termQuery("name", "a"))
+ .should(QueryBuilders.termQuery("name", "b"))
+ .should(QueryBuilders.termQuery("name", "c"))
+ .minimumShouldMatch("5"),
+ ctx
+ );
+
+ assertTrue("MSM exceeding should-count must produce FALSE literal (match-none)", result instanceof RexLiteral);
+ assertFalse("Must be boolean FALSE", RexLiteral.booleanValue(result));
+ }
+
+ public void testCalculateRequiredMatchesClampedToZeroMakesShouldOptional() throws ConversionException {
+ // "-10" with must present + 3 should → clamps to 0 → should is optional
+ // But even without must, "-10" on 3 should → result is -7 → clamp to 0 → optional
+ RexNode result = translator.convert(
+ QueryBuilders.boolQuery()
+ .must(QueryBuilders.termQuery("name", "required"))
+ .should(QueryBuilders.termQuery("brand", "a"))
+ .should(QueryBuilders.termQuery("brand", "b"))
+ .should(QueryBuilders.termQuery("brand", "c"))
+ .minimumShouldMatch("-10"),
+ ctx
+ );
+
+ assertTrue(result instanceof RexCall);
+ RexCall call = (RexCall) result;
+ // Should clauses are optional when clamped to 0 → only must clause remains
+ assertEquals(SqlKind.EQUALS, call.getKind());
+ }
+
+ public void testCalculateRequiredMatchesPercentageClampedAboveTotal() throws ConversionException {
+ // "200%" with 3 clauses → floor(3 * 200 / 100) = 6 → exceeds totalShould
+ // Not clamped at calculateRequiredMatches level; convert() handles match-none.
+ int result = MinimumShouldMatchParser.calculateRequiredMatches("200%", 3, false);
+ assertEquals("Percentage exceeding 100% returns raw computed value (> totalShould signals match-none)", 6, result);
+ }
+
+ // --- Combination cap tests ---
+
+ public void testLargeClauseCountConvertsWithConjoinedForm() throws ConversionException {
+ // 20 should clauses with MSM="10" — previously rejected by combination cap.
+ // Must now succeed and emit AND(OR(all), GTE(PLUS chain of CASE, k)).
+ BoolQueryBuilder query = QueryBuilders.boolQuery();
+ for (int i = 0; i < 20; i++) {
+ query.should(QueryBuilders.termQuery("name", "val" + i));
+ }
+ query.minimumShouldMatch("10");
+
+ RexNode result = translator.convert(query, ctx);
+ assertTrue("Must produce AND", result instanceof RexCall);
+ RexCall and = (RexCall) result;
+ assertEquals(SqlKind.AND, and.getKind());
+ assertEquals("AND must have exactly 2 children (OR pruning hint + GTE counting)", 2, and.getOperands().size());
+
+ // First child: OR of all 20 conditions
+ RexNode orChild = and.getOperands().get(0);
+ assertTrue("First conjunct must be OR", orChild instanceof RexCall);
+ assertEquals(SqlKind.OR, ((RexCall) orChild).getKind());
+ assertEquals(20, ((RexCall) orChild).getOperands().size());
+
+ // Second child: GTE comparison
+ RexNode gteChild = and.getOperands().get(1);
+ assertTrue("Second conjunct must be GTE", gteChild instanceof RexCall);
+ assertEquals(SqlKind.GREATER_THAN_OR_EQUAL, ((RexCall) gteChild).getKind());
+
+ // CASE leaf count must be linear in clause count (exactly 20 CASE nodes)
+ int caseCount = countNodes(gteChild, SqlKind.CASE);
+ assertEquals("CASE leaf count must equal clause count", 20, caseCount);
+ }
+
+ public void testVeryLargeClauseCountConvertsWithConjoinedForm() throws ConversionException {
+ // C(15,7) = 6435 — previously rejected. Must now convert with linear expression.
+ BoolQueryBuilder query = QueryBuilders.boolQuery();
+ for (int i = 0; i < 15; i++) {
+ query.should(QueryBuilders.termQuery("name", "val" + i));
+ }
+ query.minimumShouldMatch("7");
+
+ RexNode result = translator.convert(query, ctx);
+ assertTrue("Must produce AND", result instanceof RexCall);
+ RexCall and = (RexCall) result;
+ assertEquals(SqlKind.AND, and.getKind());
+ assertEquals(2, and.getOperands().size());
+
+ // OR child has 15 predicates
+ RexNode orChild = and.getOperands().get(0);
+ assertTrue(orChild instanceof RexCall);
+ assertEquals(SqlKind.OR, ((RexCall) orChild).getKind());
+ assertEquals(15, ((RexCall) orChild).getOperands().size());
+
+ // GTE child with 15 CASE leaves
+ RexNode gteChild = and.getOperands().get(1);
+ assertTrue(gteChild instanceof RexCall);
+ assertEquals(SqlKind.GREATER_THAN_OR_EQUAL, ((RexCall) gteChild).getKind());
+ assertEquals(15, countNodes(gteChild, SqlKind.CASE));
+ }
+
+ public void testConjoinedFormTruthTableEquivalence() throws ConversionException {
+ // For n=4 clauses, verify every truth assignment for k in [2, 3]:
+ // the expression is true iff at least k predicates are true.
+ for (int required = 2; required <= 3; required++) {
+ BoolQueryBuilder query = QueryBuilders.boolQuery();
+ for (int i = 0; i < 4; i++) {
+ query.should(QueryBuilders.termQuery("name", "val" + i));
+ }
+ query.minimumShouldMatch(String.valueOf(required));
+
+ RexNode result = translator.convert(query, ctx);
+ assertTrue("Must produce AND for k=" + required, result instanceof RexCall);
+ RexCall and = (RexCall) result;
+ assertEquals(SqlKind.AND, and.getKind());
+
+ // Evaluate against all 2^4 = 16 truth assignments
+ for (int mask = 0; mask < 16; mask++) {
+ int trueCount = Integer.bitCount(mask);
+ boolean expected = trueCount >= required;
+ boolean actual = evaluateConjoinedForm(and, mask, 4);
+ assertEquals("k=" + required + " mask=" + Integer.toBinaryString(mask) + " trueCount=" + trueCount, expected, actual);
+ }
+ }
+ }
+
+ public void testConjoinedFormNullConditionContributesZero() throws ConversionException {
+ // When a should clause converts to null (e.g. unsupported inner query),
+ // it should not count toward the required matches.
+ // With 3 clauses where one is effectively null after conversion,
+ // and required=2, only 2 non-null conditions remain — which equals required,
+ // so it should produce AND (all-must-match) for the non-null clauses.
+ BoolQueryBuilder query = QueryBuilders.boolQuery()
+ .should(QueryBuilders.termQuery("name", "a"))
+ .should(QueryBuilders.termQuery("name", "b"))
+ .should(QueryBuilders.termQuery("name", "c"))
+ .minimumShouldMatch("2");
+
+ // This exercises the path where all 3 convert successfully and required < size.
+ // The null-condition behavior is tested by verifying that CASE WHEN null THEN 1 ELSE 0
+ // evaluates to 0 via the truth-table test (mask with bit=0 means predicate is false/null).
+ RexNode result = translator.convert(query, ctx);
+ assertTrue(result instanceof RexCall);
+ RexCall and = (RexCall) result;
+ assertEquals(SqlKind.AND, and.getKind());
+
+ // The GTE child's CASE uses the predicate as condition — null predicate → ELSE 0
+ RexNode gteChild = and.getOperands().get(1);
+ assertTrue(gteChild instanceof RexCall);
+ assertEquals(SqlKind.GREATER_THAN_OR_EQUAL, ((RexCall) gteChild).getKind());
+ // k literal must be INTEGER 2
+ RexNode kLiteral = ((RexCall) gteChild).getOperands().get(1);
+ assertTrue("k must be a literal", kLiteral instanceof RexLiteral);
+ assertEquals("k must be INTEGER typed", SqlTypeName.INTEGER, ((RexLiteral) kLiteral).getType().getSqlTypeName());
+ }
+
+ public void testConjoinedFormOrConjunctSurvivesPlanning() throws ConversionException {
+ // Safeguard: the OR conjunct must be present in the AND. If a future Calcite
+ // simplification removes it (detecting the implication), this test fails loudly.
+ BoolQueryBuilder query = QueryBuilders.boolQuery()
+ .should(QueryBuilders.termQuery("name", "a"))
+ .should(QueryBuilders.termQuery("name", "b"))
+ .should(QueryBuilders.termQuery("name", "c"))
+ .minimumShouldMatch("2");
+
+ RexNode result = translator.convert(query, ctx);
+ assertTrue(result instanceof RexCall);
+ RexCall and = (RexCall) result;
+ assertEquals(SqlKind.AND, and.getKind());
+
+ // Assert the OR conjunct is the first operand and has the right shape
+ RexNode firstChild = and.getOperands().get(0);
+ assertTrue("OR pruning hint must survive as first AND child", firstChild instanceof RexCall);
+ assertEquals("First child must be OR", SqlKind.OR, ((RexCall) firstChild).getKind());
+ assertEquals("OR must contain all should conditions", 3, ((RexCall) firstChild).getOperands().size());
+
+ // Assert flatness of the outer AND
+ assertTrue("Outer expression must be flat", RexUtil.isFlat(result));
+ }
+
+ // --- MSM exceeding should-count: legacy matches nothing (witnessed via
+ // BoolQueryBuilderTests.testMinShouldMatchBiggerThanNumberOfShouldClauses:
+ // Lucene BooleanQuery accepts MSM=3 with 2 SHOULD clauses, produces impossible constraint) ---
+
+ public void testMsmExceedingShouldCountProducesMatchNone() throws ConversionException {
+ // Legacy: MSM=5 with 3 should clauses → Lucene accepts, matches zero documents.
+ // Translator must produce boolean FALSE literal (match-none).
+ RexNode result = translator.convert(
+ QueryBuilders.boolQuery()
+ .should(QueryBuilders.termQuery("name", "a"))
+ .should(QueryBuilders.termQuery("name", "b"))
+ .should(QueryBuilders.termQuery("name", "c"))
+ .minimumShouldMatch("5"),
+ ctx
+ );
+
+ assertTrue("MSM exceeding should-count must produce FALSE literal", result instanceof RexLiteral);
+ assertFalse("Must be boolean FALSE (match-none)", RexLiteral.booleanValue(result));
+ }
+
+ public void testMsmExceedingShouldCountWithMustProducesMatchNone() throws ConversionException {
+ // Even with must clauses, if MSM exceeds should count, the whole bool matches nothing.
+ RexNode result = translator.convert(
+ QueryBuilders.boolQuery()
+ .must(QueryBuilders.termQuery("name", "required"))
+ .should(QueryBuilders.termQuery("brand", "a"))
+ .should(QueryBuilders.termQuery("brand", "b"))
+ .minimumShouldMatch("5"),
+ ctx
+ );
+
+ assertTrue("MSM exceeding should-count must produce FALSE literal even with must", result instanceof RexLiteral);
+ assertFalse("Must be boolean FALSE (match-none)", RexLiteral.booleanValue(result));
+ }
+
+ // --- Parameter audit: boost, _name, adjustPureNegative ---
+
+ public void testNonDefaultBoostThrowsConversionException() {
+ // Legacy: AbstractQueryBuilder.toQuery wraps result in BoostQuery when boost != 1.0f.
+ // We cannot represent scoring in Calcite, so reject non-default boost.
+ BoolQueryBuilder query = QueryBuilders.boolQuery().must(QueryBuilders.termQuery("name", "test")).boost(2.0f);
+
+ ConversionException ex = expectThrows(ConversionException.class, () -> translator.convert(query, ctx));
+ assertTrue("Message must mention boost", ex.getMessage().contains("boost"));
+ }
+
+ public void testQueryNameThrowsConversionException() {
+ // Legacy: AbstractQueryBuilder.toQuery registers named query for matched_queries response.
+ // No Calcite equivalent; reject with clear message.
+ BoolQueryBuilder query = QueryBuilders.boolQuery().must(QueryBuilders.termQuery("name", "test")).queryName("my_bool");
+
+ ConversionException ex = expectThrows(ConversionException.class, () -> translator.convert(query, ctx));
+ assertTrue("Message must mention _name", ex.getMessage().contains("_name"));
+ }
+
+ public void testAdjustPureNegativeFalsePureNegativeReturnsFalseLiteral() throws ConversionException {
+ // Legacy: adjustPureNegative=false on a pure-negative bool skips fixNegativeQueryIfNeeded,
+ // so no match-all is injected and the BooleanQuery with only MUST_NOT clauses matches nothing.
+ // Citation: BoolQueryBuilder.doToQuery:338, Queries.isNegativeQuery:113-119.
+ RexNode result = translator.convert(
+ QueryBuilders.boolQuery().mustNot(QueryBuilders.termQuery("name", "test")).adjustPureNegative(false),
+ ctx
+ );
+
+ assertTrue("Pure-negative with adjustPureNegative=false must produce FALSE literal", result instanceof RexLiteral);
+ assertFalse("Must be boolean FALSE (match-none)", RexLiteral.booleanValue(result));
+ }
+
+ public void testAdjustPureNegativeFalseMustPlusMustNotAccepted() throws ConversionException {
+ // Legacy: adjustPureNegative=false is a no-op when must clauses exist because
+ // Queries.isNegativeQuery requires ALL clauses to be prohibited.
+ // Citation: Queries.isNegativeQuery:113-119.
+ BoolQueryBuilder queryWithFlag = QueryBuilders.boolQuery()
+ .must(QueryBuilders.termQuery("name", "keep"))
+ .mustNot(QueryBuilders.termQuery("brand", "excluded"))
+ .adjustPureNegative(false);
+ BoolQueryBuilder queryDefault = QueryBuilders.boolQuery()
+ .must(QueryBuilders.termQuery("name", "keep"))
+ .mustNot(QueryBuilders.termQuery("brand", "excluded"));
+
+ RexNode resultWithFlag = translator.convert(queryWithFlag, ctx);
+ RexNode resultDefault = translator.convert(queryDefault, ctx);
+
+ assertEquals(
+ "must+must_not with adjustPureNegative=false must equal default-true result",
+ resultDefault.toString(),
+ resultWithFlag.toString()
+ );
+ }
+
+ public void testAdjustPureNegativeFalseShouldOnlyAccepted() throws ConversionException {
+ // Legacy: should-only is not pure-negative, so adjustPureNegative=false is a no-op.
+ BoolQueryBuilder queryWithFlag = QueryBuilders.boolQuery()
+ .should(QueryBuilders.termQuery("name", "a"))
+ .should(QueryBuilders.termQuery("name", "b"))
+ .adjustPureNegative(false);
+ BoolQueryBuilder queryDefault = QueryBuilders.boolQuery()
+ .should(QueryBuilders.termQuery("name", "a"))
+ .should(QueryBuilders.termQuery("name", "b"));
+
+ RexNode resultWithFlag = translator.convert(queryWithFlag, ctx);
+ RexNode resultDefault = translator.convert(queryDefault, ctx);
+
+ assertEquals(
+ "should-only with adjustPureNegative=false must equal default-true result",
+ resultDefault.toString(),
+ resultWithFlag.toString()
+ );
+ }
+
+ public void testAdjustPureNegativeFalseFilterPlusMustNotAccepted() throws ConversionException {
+ // Legacy: filter+must_not is not pure-negative (has non-prohibited clauses), so flag is no-op.
+ BoolQueryBuilder queryWithFlag = QueryBuilders.boolQuery()
+ .filter(QueryBuilders.termQuery("name", "active"))
+ .mustNot(QueryBuilders.termQuery("brand", "excluded"))
+ .adjustPureNegative(false);
+ BoolQueryBuilder queryDefault = QueryBuilders.boolQuery()
+ .filter(QueryBuilders.termQuery("name", "active"))
+ .mustNot(QueryBuilders.termQuery("brand", "excluded"));
+
+ RexNode resultWithFlag = translator.convert(queryWithFlag, ctx);
+ RexNode resultDefault = translator.convert(queryDefault, ctx);
+
+ assertEquals(
+ "filter+must_not with adjustPureNegative=false must equal default-true result",
+ resultDefault.toString(),
+ resultWithFlag.toString()
+ );
+ }
+
+ public void testAdjustPureNegativeDefaultTruePureNegativeProducesNot() throws ConversionException {
+ // Legacy: adjustPureNegative=true (default) injects match-all via fixNegativeQueryIfNeeded.
+ // Our table scan is the implicit match-all, so pure-negative produces NOT.
+ RexNode result = translator.convert(QueryBuilders.boolQuery().mustNot(QueryBuilders.termQuery("name", "test")), ctx);
+
+ assertTrue("Pure-negative with default adjustPureNegative must produce NOT", result instanceof RexCall);
+ assertEquals(SqlKind.NOT, ((RexCall) result).getKind());
+ }
+
+ public void testDefaultBoostAccepted() throws ConversionException {
+ // boost=1.0f (default) should NOT throw
+ RexNode result = translator.convert(QueryBuilders.boolQuery().must(QueryBuilders.termQuery("name", "test")).boost(1.0f), ctx);
+ assertNotNull(result);
+ }
+
+ // --- Empty bool and pure-negative ---
+
+ public void testEmptyBoolProducesTrueLiteral() throws ConversionException {
+ // Legacy: BoolQueryBuilder.doRewrite returns MatchAllQueryBuilder when clauses==0.
+ // Citation: BoolQueryBuilder.doRewrite line ~279 and doToQuery line 333.
+ RexNode result = translator.convert(QueryBuilders.boolQuery(), ctx);
+
+ assertTrue("Empty bool must produce TRUE literal (match-all)", result instanceof RexLiteral);
+ assertTrue("Must be boolean TRUE", RexLiteral.booleanValue(result));
+ }
+
+ public void testPureNegativeBoolProducesNot() throws ConversionException {
+ // Legacy: adjustPureNegative=true (default) adds MatchAllDocsQuery FILTER,
+ // meaning "match all EXCEPT these". Our table scan is the implicit match-all,
+ // so pure-negative just produces NOT conditions.
+ // Citation: Queries.fixNegativeQueryIfNeeded lines 111-121.
+ RexNode result = translator.convert(
+ QueryBuilders.boolQuery()
+ .mustNot(QueryBuilders.termQuery("name", "excluded1"))
+ .mustNot(QueryBuilders.termQuery("brand", "excluded2")),
+ ctx
+ );
+
+ assertTrue(result instanceof RexCall);
+ RexCall call = (RexCall) result;
+ assertEquals(SqlKind.AND, call.getKind());
+ assertEquals(2, call.getOperands().size());
+ // Each operand should be NOT(EQUALS)
+ for (RexNode operand : call.getOperands()) {
+ assertTrue(operand instanceof RexCall);
+ assertEquals(SqlKind.NOT, ((RexCall) operand).getKind());
+ }
+ }
+
+ // --- Nested bool recursion with isFlat assertion ---
+
+ public void testNestedBoolInShouldInMustIsFlat() throws ConversionException {
+ // bool(must: [bool(should: [bool(must: [term1, term2]), term3])])
+ // After flattening, the top-level result must satisfy RexUtil.isFlat.
+ RexNode result = translator.convert(
+ QueryBuilders.boolQuery()
+ .must(
+ QueryBuilders.boolQuery()
+ .should(
+ QueryBuilders.boolQuery().must(QueryBuilders.termQuery("name", "a")).must(QueryBuilders.termQuery("brand", "b"))
+ )
+ .should(QueryBuilders.termQuery("rating", 3.0))
+ ),
+ ctx
+ );
+
+ assertNotNull(result);
+ assertTrue("Nested bool composition must produce flat RexNode", RexUtil.isFlat(result));
+ }
+
+ // --- Parser hardening: NumberFormatException wrapping ---
+
+ public void testParseIntegerInvalidThrowsConversionExceptionWithValue() {
+ ConversionException ex = expectThrows(
+ ConversionException.class,
+ () -> MinimumShouldMatchParser.calculateRequiredMatches("abc", 3, false)
+ );
+ assertTrue("Message must contain the offending value", ex.getMessage().contains("abc"));
+ }
+
+ public void testParsePercentageInvalidThrowsConversionExceptionWithValue() {
+ ConversionException ex = expectThrows(
+ ConversionException.class,
+ () -> MinimumShouldMatchParser.calculateRequiredMatches("xyz%", 3, false)
+ );
+ assertTrue("Message must contain the offending value", ex.getMessage().contains("xyz"));
+ }
+
+ public void testParseCombinationInvalidThresholdThrowsConversionException() {
+ ConversionException ex = expectThrows(
+ ConversionException.class,
+ () -> MinimumShouldMatchParser.calculateRequiredMatches("abc<75%", 3, false)
+ );
+ assertTrue("Message must contain the offending value", ex.getMessage().contains("abc"));
+ }
+
+ public void testParseCombinationTrailingLessThanThrowsConversionException() {
+ // "5<" is malformed — split produces only one part
+ ConversionException ex = expectThrows(
+ ConversionException.class,
+ () -> MinimumShouldMatchParser.calculateRequiredMatches("5<", 3, false)
+ );
+ assertTrue("Message must mention Malformed", ex.getMessage().contains("Malformed"));
+ }
+
+ // --- Should-gate local branch tests: exact-equal AND vs over-large FALSE ---
+
+ public void testShouldGateExactEqualProducesAnd() throws ConversionException {
+ // When requiredMatches == shouldConditions.size(), all must match → AND.
+ // MSM="3" with 3 should clauses → requiredMatches=3 == size=3 → AND.
+ RexNode result = translator.convert(
+ QueryBuilders.boolQuery()
+ .should(QueryBuilders.termQuery("name", "a"))
+ .should(QueryBuilders.termQuery("name", "b"))
+ .should(QueryBuilders.termQuery("name", "c"))
+ .minimumShouldMatch("3"),
+ ctx
+ );
+
+ assertTrue("Exact-equal gate must produce AND", result instanceof RexCall);
+ assertEquals(SqlKind.AND, ((RexCall) result).getKind());
+ assertEquals(3, ((RexCall) result).getOperands().size());
+ }
+
+ public void testShouldGateOverLargeProducesFalse() throws ConversionException {
+ // When requiredMatches > shouldConditions.size(), the constraint is unsatisfiable → FALSE.
+ // The upstream gate (requiredMatches > totalShould) catches this via convert(); the local
+ // gate is a robustness guard against future code paths where shouldConditions.size() could
+ // diverge from totalShould. This test exercises the upstream gate which has identical semantics.
+ RexNode result = translator.convert(
+ QueryBuilders.boolQuery()
+ .should(QueryBuilders.termQuery("name", "a"))
+ .should(QueryBuilders.termQuery("name", "b"))
+ .minimumShouldMatch("5"),
+ ctx
+ );
+
+ assertTrue("Over-large MSM must produce FALSE literal", result instanceof RexLiteral);
+ assertFalse("Must be boolean FALSE (match-none)", RexLiteral.booleanValue(result));
+ }
+
+ // --- Helper methods for conjoined-form tests ---
+
+ /** Counts nodes of a given SqlKind in the expression tree recursively. */
+ private int countNodes(RexNode node, SqlKind kind) {
+ int count = 0;
+ if (node instanceof RexCall) {
+ RexCall call = (RexCall) node;
+ if (call.getKind() == kind) {
+ count++;
+ }
+ for (RexNode operand : call.getOperands()) {
+ count += countNodes(operand, kind);
+ }
+ }
+ return count;
+ }
+
+ /**
+ * Evaluates the conjoined form AND(OR(p1..pn), GTE(PLUS(CASE...), k)) against a truth mask.
+ * Bit i of mask indicates whether predicate i is true. Uses structural reduction.
+ */
+ private boolean evaluateConjoinedForm(RexCall and, int mask, int n) {
+ // OR child: true iff any bit is set
+ boolean orResult = false;
+ for (int i = 0; i < n; i++) {
+ if ((mask & (1 << i)) != 0) {
+ orResult = true;
+ break;
+ }
+ }
+
+ // GTE child: count of true predicates >= k
+ RexCall gte = (RexCall) and.getOperands().get(1);
+ RexLiteral kLiteral = (RexLiteral) gte.getOperands().get(1);
+ int k = ((Number) kLiteral.getValue()).intValue();
+ int trueCount = Integer.bitCount(mask);
+
+ return orResult && trueCount >= k;
+ }
+}
diff --git a/sandbox/plugins/dsl-query-executor/src/test/java/org/opensearch/dsl/query/MinimumShouldMatchParserTests.java b/sandbox/plugins/dsl-query-executor/src/test/java/org/opensearch/dsl/query/MinimumShouldMatchParserTests.java
new file mode 100644
index 0000000000000..6b9d89a844778
--- /dev/null
+++ b/sandbox/plugins/dsl-query-executor/src/test/java/org/opensearch/dsl/query/MinimumShouldMatchParserTests.java
@@ -0,0 +1,112 @@
+/*
+ * 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.
+ */
+
+package org.opensearch.dsl.query;
+
+import org.opensearch.dsl.converter.ConversionException;
+import org.opensearch.test.OpenSearchTestCase;
+
+public class MinimumShouldMatchParserTests extends OpenSearchTestCase {
+
+ // Test integer parsing
+ public void testParsePositiveInteger() throws ConversionException {
+ int result = MinimumShouldMatchParser.calculateRequiredMatches("2", 5, false);
+ assertEquals(2, result);
+ }
+
+ public void testParseNegativeInteger() throws ConversionException {
+ int result = MinimumShouldMatchParser.calculateRequiredMatches("-1", 5, false);
+ assertEquals(4, result); // 5 - 1 = 4
+ }
+
+ // Test percentage parsing
+ public void testParsePositivePercentage() throws ConversionException {
+ int result = MinimumShouldMatchParser.calculateRequiredMatches("70%", 4, false);
+ assertEquals(2, result); // floor(4 * 70 / 100) = 2
+ }
+
+ public void testParseNegativePercentage() throws ConversionException {
+ int result = MinimumShouldMatchParser.calculateRequiredMatches("-30%", 4, false);
+ assertEquals(3, result); // 4 - floor(4 * 30 / 100) = 3
+ }
+
+ // Test combination parsing
+ public void testParseCombinationBelowThreshold() throws ConversionException {
+ int result = MinimumShouldMatchParser.calculateRequiredMatches("2<75%", 2, false);
+ assertEquals(2, result); // total <= 2, match all
+ }
+
+ public void testParseCombinationAboveThreshold() throws ConversionException {
+ int result = MinimumShouldMatchParser.calculateRequiredMatches("2<75%", 4, false);
+ assertEquals(3, result); // total > 2, floor(4 * 75 / 100) = 3
+ }
+
+ // Test multiple combinations
+ public void testParseMultipleCombinationsLow() throws ConversionException {
+ int result = MinimumShouldMatchParser.calculateRequiredMatches("3<-1 5<50%", 3, false);
+ assertEquals(3, result); // total <= 3, match all
+ }
+
+ public void testParseMultipleCombinationsMid() throws ConversionException {
+ int result = MinimumShouldMatchParser.calculateRequiredMatches("3<-1 5<50%", 4, false);
+ assertEquals(3, result); // 3 < 4 <= 5, so -1 = 4 - 1 = 3
+ }
+
+ public void testParseMultipleCombinationsHigh() throws ConversionException {
+ int result = MinimumShouldMatchParser.calculateRequiredMatches("3<-1 5<50%", 6, false);
+ assertEquals(3, result); // total > 5, floor(6 * 50 / 100) = 3
+ }
+
+ // Test default behavior
+ public void testDefaultWithoutMust() throws ConversionException {
+ int result = MinimumShouldMatchParser.calculateRequiredMatches(null, 3, false);
+ assertEquals(1, result); // No must clause, at least 1 should match
+ }
+
+ public void testDefaultWithMust() throws ConversionException {
+ int result = MinimumShouldMatchParser.calculateRequiredMatches(null, 3, true);
+ assertEquals(0, result); // Has must clause, should is optional
+ }
+
+ // Non-finite and fractional percentage rejection tests.
+ // WHY: Legacy Queries.calculateMinShouldMatch (line ~199) uses Integer.parseInt for the
+ // percentage numeric part, rejecting non-integer and non-finite values.
+
+ public void testParsePercentageNaNThrowsConversionException() {
+ ConversionException ex = expectThrows(
+ ConversionException.class,
+ () -> MinimumShouldMatchParser.calculateRequiredMatches("NaN%", 4, false)
+ );
+ assertTrue("Message must contain the offending value", ex.getMessage().contains("NaN%"));
+ }
+
+ public void testParsePercentageInfinityThrowsConversionException() {
+ ConversionException ex = expectThrows(
+ ConversionException.class,
+ () -> MinimumShouldMatchParser.calculateRequiredMatches("Infinity%", 4, false)
+ );
+ assertTrue("Message must contain the offending value", ex.getMessage().contains("Infinity%"));
+ }
+
+ public void testParsePercentageNegativeInfinityThrowsConversionException() {
+ ConversionException ex = expectThrows(
+ ConversionException.class,
+ () -> MinimumShouldMatchParser.calculateRequiredMatches("-Infinity%", 4, false)
+ );
+ assertTrue("Message must contain the offending value", ex.getMessage().contains("-Infinity%"));
+ }
+
+ public void testParsePercentageFractionalThrowsConversionException() {
+ // Legacy Integer.parseInt rejects fractional values like "70.5".
+ ConversionException ex = expectThrows(
+ ConversionException.class,
+ () -> MinimumShouldMatchParser.calculateRequiredMatches("70.5%", 4, false)
+ );
+ assertTrue("Message must contain the offending value", ex.getMessage().contains("70.5%"));
+ }
+}
diff --git a/sandbox/plugins/dsl-query-executor/src/test/resources/golden/bool_minimum_should_match_hits.json b/sandbox/plugins/dsl-query-executor/src/test/resources/golden/bool_minimum_should_match_hits.json
new file mode 100644
index 0000000000000..5edc44358efb4
--- /dev/null
+++ b/sandbox/plugins/dsl-query-executor/src/test/resources/golden/bool_minimum_should_match_hits.json
@@ -0,0 +1,42 @@
+{
+ "testName": "bool_minimum_should_match_hits",
+ "indexName": "test-index",
+ "indexMapping": {
+ "name": "VARCHAR",
+ "price": "INTEGER",
+ "brand": "VARCHAR",
+ "rating": "DOUBLE"
+ },
+ "planType": "HITS",
+ "inputDsl": {
+ "query": {
+ "bool": {
+ "should": [
+ {"term": {"name": "laptop"}},
+ {"term": {"brand": "BrandA"}},
+ {"exists": {"field": "rating"}}
+ ],
+ "minimum_should_match": "2"
+ }
+ }
+ },
+ "expectedRelNodePlan": [
+ "LogicalFilter(condition=[AND(OR(=($0, CAST('laptop':VARCHAR):VARCHAR), =($2, CAST('BrandA':VARCHAR):VARCHAR), IS NOT NULL($3)), >=(+(+(CASE(=($0, CAST('laptop':VARCHAR):VARCHAR), 1, 0), CASE(=($2, CAST('BrandA':VARCHAR):VARCHAR), 1, 0)), CASE(IS NOT NULL($3), 1, 0)), 2))])",
+ " LogicalTableScan(table=[[test-index]])"
+ ],
+ "mockResultFieldNames": ["name", "price", "brand", "rating"],
+ "mockResultRows": [
+ ["laptop", 999, "BrandA", 4.5]
+ ],
+ "expectedOutputDsl": {
+ "num_reduce_phases": 0,
+ "hits": {
+ "total": {
+ "value": 0,
+ "relation": "eq"
+ },
+ "max_score": 0.0,
+ "hits": []
+ }
+ }
+}
diff --git a/sandbox/plugins/dsl-query-executor/src/test/resources/golden/bool_must_should_hits.json b/sandbox/plugins/dsl-query-executor/src/test/resources/golden/bool_must_should_hits.json
new file mode 100644
index 0000000000000..13ac956e543bd
--- /dev/null
+++ b/sandbox/plugins/dsl-query-executor/src/test/resources/golden/bool_must_should_hits.json
@@ -0,0 +1,43 @@
+{
+ "testName": "bool_must_should_hits",
+ "indexName": "test-index",
+ "indexMapping": {
+ "name": "VARCHAR",
+ "price": "INTEGER",
+ "brand": "VARCHAR",
+ "rating": "DOUBLE"
+ },
+ "planType": "HITS",
+ "inputDsl": {
+ "query": {
+ "bool": {
+ "must": [
+ {"term": {"name": "laptop"}}
+ ],
+ "should": [
+ {"term": {"brand": "BrandA"}},
+ {"term": {"brand": "BrandB"}}
+ ]
+ }
+ }
+ },
+ "expectedRelNodePlan": [
+ "LogicalFilter(condition=[=($0, CAST('laptop':VARCHAR):VARCHAR)])",
+ " LogicalTableScan(table=[[test-index]])"
+ ],
+ "mockResultFieldNames": ["name", "price", "brand", "rating"],
+ "mockResultRows": [
+ ["laptop", 999, "BrandA", 4.5]
+ ],
+ "expectedOutputDsl": {
+ "num_reduce_phases": 0,
+ "hits": {
+ "total": {
+ "value": 0,
+ "relation": "eq"
+ },
+ "max_score": 0.0,
+ "hits": []
+ }
+ }
+}