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 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 conditions = new ArrayList<>(); + + // Must clauses (AND) + for (QueryBuilder mustClause : boolQuery.must()) { + conditions.add(queryRegistry.convert(mustClause, ctx)); + } + + // Filter clauses (AND) — identical to must at Calcite level (scoring irrelevant) + for (QueryBuilder filterClause : boolQuery.filter()) { + conditions.add(queryRegistry.convert(filterClause, ctx)); + } + + // Should clauses with minimum_should_match + if (!boolQuery.should().isEmpty()) { + RexNode shouldCondition = processShouldClauses(boolQuery, ctx); + if (shouldCondition != null) { + // Check if should processing determined match-none (FALSE literal) + if (shouldCondition.isAlwaysFalse()) { + return ctx.getRexBuilder().makeLiteral(false); + } + conditions.add(shouldCondition); + } + } + + // Must_not clauses (NOT) with double-negation elimination + for (QueryBuilder mustNotClause : boolQuery.mustNot()) { + RexNode condition = queryRegistry.convert(mustNotClause, ctx); + if (condition instanceof RexCall && ((RexCall) condition).getOperator() == SqlStdOperatorTable.NOT) { + conditions.add(((RexCall) condition).getOperands().get(0)); + } else { + conditions.add(ctx.getRexBuilder().makeCall(SqlStdOperatorTable.NOT, condition)); + } + } + + // Flatten nested ANDs to satisfy Calcite's RexUtil.isFlat requirement + List flattenedConditions = flattenConditions(conditions, SqlStdOperatorTable.AND); + + if (flattenedConditions.isEmpty()) { + // Empty bool → match-all. Citation: BoolQueryBuilder.doRewrite line ~279. + return ctx.getRexBuilder().makeLiteral(true); + } else if (flattenedConditions.size() == 1) { + return flattenedConditions.get(0); + } else { + return ctx.getRexBuilder().makeCall(SqlStdOperatorTable.AND, flattenedConditions); + } + } + + /** + * Flattens nested conditions with the same operator (AND or OR). + * Example: AND(AND(a, b), c) becomes AND(a, b, c). + */ + private List flattenConditions(List conditions, org.apache.calcite.sql.SqlOperator operator) { + List flattened = new ArrayList<>(); + for (RexNode condition : conditions) { + if (condition instanceof RexCall && ((RexCall) condition).getOperator() == operator) { + flattened.addAll(((RexCall) condition).getOperands()); + } else { + flattened.add(condition); + } + } + return flattened; + } + + /** + * Processes should clauses with minimum_should_match logic. + * + * @return RexNode for should logic, FALSE literal if MSM is unsatisfiable, or null if optional + */ + private RexNode processShouldClauses(BoolQueryBuilder boolQuery, ConversionContext ctx) throws ConversionException { + List shouldClauses = boolQuery.should(); + int totalShould = shouldClauses.size(); + + boolean hasRequired = !boolQuery.must().isEmpty() || !boolQuery.filter().isEmpty(); + String minimumShouldMatch = boolQuery.minimumShouldMatch(); + + int requiredMatches = MinimumShouldMatchParser.calculateRequiredMatches(minimumShouldMatch, totalShould, hasRequired); + + if (requiredMatches == 0) { + return null; // Should clauses are optional + } + + // WHY: Legacy Lucene accepts MSM > shouldCount but matches nothing (impossible constraint). + // Witnessed: BoolQueryBuilderTests.testMinShouldMatchBiggerThanNumberOfShouldClauses sets + // MSM=3 on 2 SHOULD clauses — Lucene accepts, query matches zero documents. + if (requiredMatches > totalShould) { + return ctx.getRexBuilder().makeLiteral(false); + } + + List shouldConditions = new ArrayList<>(); + for (QueryBuilder shouldClause : shouldClauses) { + shouldConditions.add(queryRegistry.convert(shouldClause, ctx)); + } + + if (shouldConditions.isEmpty()) { + return null; + } + + // WHY: Unsatisfiable after conversion — requiredMatches exceeds converted clause count. + // Legacy Lucene accepts an over-large minimum_should_match and matches zero documents. + if (requiredMatches > shouldConditions.size()) { + return ctx.getRexBuilder().makeLiteral(false); + } + + if (requiredMatches == shouldConditions.size()) { + // All should conditions must match — produce AND + List flatAnd = flattenConditions(shouldConditions, SqlStdOperatorTable.AND); + return flatAnd.size() == 1 ? flatAnd.get(0) : ctx.getRexBuilder().makeCall(SqlStdOperatorTable.AND, flatAnd); + } + + if (requiredMatches == 1) { + List flatOr = flattenConditions(shouldConditions, SqlStdOperatorTable.OR); + return flatOr.size() == 1 ? flatOr.get(0) : ctx.getRexBuilder().makeCall(SqlStdOperatorTable.OR, flatOr); + } + + return createMinimumMatchCondition(shouldConditions, requiredMatches, ctx); + } + + /** + * Creates the conjoined form for minimum_should_match when 1 less-than required less-than n. + * Emits AND(OR(p1..pn), GTE(left-deep PLUS chain of CASE(pi, 1, 0), k)). + * + *

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 conditions, int required, ConversionContext ctx) { + var rexBuilder = ctx.getRexBuilder(); + var typeFactory = rexBuilder.getTypeFactory(); + var intType = typeFactory.createSqlType(SqlTypeName.INTEGER); + + RexNode one = rexBuilder.makeLiteral(1, intType); + RexNode zero = rexBuilder.makeLiteral(0, intType); + RexNode kLiteral = rexBuilder.makeLiteral(required, intType); + + // Build left-deep PLUS chain of CASE(pi, 1, 0) + RexNode sum = rexBuilder.makeCall(SqlStdOperatorTable.CASE, conditions.get(0), one, zero); + for (int i = 1; i < conditions.size(); i++) { + RexNode caseExpr = rexBuilder.makeCall(SqlStdOperatorTable.CASE, conditions.get(i), one, zero); + sum = rexBuilder.makeCall(SqlStdOperatorTable.PLUS, sum, caseExpr); + } + + // GTE comparison: sum >= k + RexNode gteExpr = rexBuilder.makeCall(SqlStdOperatorTable.GREATER_THAN_OR_EQUAL, sum, kLiteral); + + // OR pruning hint — redundant but enables page pruning via column-vs-constant leaves. + // WHY: AND intersects per-child pruning bitmaps in the analytics backend page pruner + // (page_pruner.rs:704-711). The counting child becomes an opaque all-true vector, so + // only the OR child's leaf predicates drive pruning. Without the OR, no pruning occurs. + List flatOr = flattenConditions(conditions, SqlStdOperatorTable.OR); + RexNode orConjunct = flatOr.size() == 1 ? flatOr.get(0) : rexBuilder.makeCall(SqlStdOperatorTable.OR, flatOr); + + // Outer AND must be flat (no nested AND children) + List andChildren = new ArrayList<>(2); + andChildren.add(orConjunct); + andChildren.add(gteExpr); + List flatAnd = flattenConditions(andChildren, SqlStdOperatorTable.AND); + return rexBuilder.makeCall(SqlStdOperatorTable.AND, flatAnd); + } +} diff --git a/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/query/MinimumShouldMatchParser.java b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/query/MinimumShouldMatchParser.java new file mode 100644 index 0000000000000..ef0403cb1a6bf --- /dev/null +++ b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/query/MinimumShouldMatchParser.java @@ -0,0 +1,131 @@ +/* + * 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; + +/** + * Parses the minimum_should_match specification string into a required-match count. + * Supports integer, percentage, single-combination, and multiple-combination forms. + * + *

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": [] + } + } +}