From 661278901f352248ecbcdc3e97d9f9d6afe6e45c Mon Sep 17 00:00:00 2001 From: Abhishek Som Date: Thu, 26 Mar 2026 03:26:35 +0530 Subject: [PATCH 1/3] Added support for Terms query to convert in RelNode format --- plugins/query-dsl-calcite/README.md | 13 +-- .../dsl/DslLogicalPlanIntegrationIT.java | 86 +++++++++++++++++++ .../dsl/query/QueryRegistryFactory.java | 1 + .../dsl/query/TermsQueryTranslator.java | 68 +++++++++++++++ 4 files changed, 162 insertions(+), 6 deletions(-) create mode 100644 plugins/query-dsl-calcite/src/main/java/org/opensearch/dsl/query/TermsQueryTranslator.java diff --git a/plugins/query-dsl-calcite/README.md b/plugins/query-dsl-calcite/README.md index 07b603180d0a8..d6f49b488b215 100644 --- a/plugins/query-dsl-calcite/README.md +++ b/plugins/query-dsl-calcite/README.md @@ -61,12 +61,13 @@ RelNode (Calcite Logical Plan) ### Queries -| DSL Query | Calcite Representation | -|-----------|------------------------| -| `term` | `=($field, value)` — equality filter | -| `range` (gte, lte, gt, lt) | `AND(>=($field, min), <=($field, max))` — range filter | -| `bool` (must + filter) | `AND(condition1, condition2, ...)` — flattened conjunction | -| `match_all` | Skipped (boolean literal `TRUE`) | +| DSL Query | Calcite Representation | +|-----------|---------------------------------------------------------------------------| +| `term` | `=($field, value)` — equality filter | +| `range` (gte, lte, gt, lt) | `AND(>=($field, min), <=($field, max))` — range filter | +| `bool` (must + filter) | `AND(condition1, condition2, ...)` — flattened conjunction | +| `match_all` | Skipped (boolean literal `TRUE`) | +| `exists` | `IS NOT NULL($field)` — field existence check & boost not supported check | ### Aggregations diff --git a/plugins/query-dsl-calcite/src/internalClusterTest/java/org/opensearch/dsl/DslLogicalPlanIntegrationIT.java b/plugins/query-dsl-calcite/src/internalClusterTest/java/org/opensearch/dsl/DslLogicalPlanIntegrationIT.java index c9b5517206c54..e785bf40b7094 100644 --- a/plugins/query-dsl-calcite/src/internalClusterTest/java/org/opensearch/dsl/DslLogicalPlanIntegrationIT.java +++ b/plugins/query-dsl-calcite/src/internalClusterTest/java/org/opensearch/dsl/DslLogicalPlanIntegrationIT.java @@ -70,6 +70,92 @@ public void testTermQueryConversion() throws Exception { // Verify it's an equality condition } + /** + * Test: Terms query conversion. + * Verifies that a terms query is converted to a LogicalFilter with IN condition. + * + * DSL Query: + * { + * "query": { + * "terms": { + * "category": ["electronics", "computers", "laptops"] + * } + * } + * } + * + * Expected Calcite Plan: + * LogicalFilter(condition=[IN($0, 'electronics', 'computers', 'laptops')]) + * LogicalTableScan(table=[[test-terms-query]]) + */ + public void testTermsQueryConversion() throws Exception { + String indexName = "test-terms-query"; + String mapping = "{" + + "\"properties\": {" + + " \"category\": {\"type\": \"keyword\"}," + + " \"price\": {\"type\": \"long\"}" + + "}" + + "}"; + client().admin().indices().prepareCreate(indexName) + .setMapping(mapping) + .get(); + ensureGreen(indexName); + + SearchSourceBuilder searchSource = new SearchSourceBuilder(); + searchSource.query(QueryBuilders.termsQuery("category", "electronics", "computers", "laptops")); + + SearchResponse response = convertDsl(searchSource, indexName); + assertNotNull("SearchResponse should not be null", response); + } + + /** + * Test: Terms query with non-default boost throws RuntimeException. + */ + public void testTermsQueryWithBoostThrowsException() throws Exception { + String indexName = "test-terms-boost"; + String mapping = "{\"properties\": {\"category\": {\"type\": \"keyword\"}}}"; + client().admin().indices().prepareCreate(indexName).setMapping(mapping).get(); + ensureGreen(indexName); + + SearchSourceBuilder searchSource = new SearchSourceBuilder(); + searchSource.query(QueryBuilders.termsQuery("category", "electronics").boost(2.0f)); + + RuntimeException exception = expectThrows(RuntimeException.class, () -> convertDsl(searchSource, indexName)); + assertThat(exception.getMessage(), containsString("does not support non-default boost")); + } + + /** + * Test: Terms query with _name throws RuntimeException. + */ + public void testTermsQueryWithNameThrowsException() throws Exception { + String indexName = "test-terms-name"; + String mapping = "{\"properties\": {\"category\": {\"type\": \"keyword\"}}}"; + client().admin().indices().prepareCreate(indexName).setMapping(mapping).get(); + ensureGreen(indexName); + + SearchSourceBuilder searchSource = new SearchSourceBuilder(); + searchSource.query(QueryBuilders.termsQuery("category", "electronics").queryName("my_query")); + + RuntimeException exception = expectThrows(RuntimeException.class, () -> convertDsl(searchSource, indexName)); + assertThat(exception.getMessage(), containsString("does not support _name")); + } + + /** + * Test: Terms query with non-default value_type throws RuntimeException. + */ + public void testTermsQueryWithValueTypeThrowsException() throws Exception { + String indexName = "test-terms-valuetype"; + String mapping = "{\"properties\": {\"category\": {\"type\": \"keyword\"}}}"; + client().admin().indices().prepareCreate(indexName).setMapping(mapping).get(); + ensureGreen(indexName); + + SearchSourceBuilder searchSource = new SearchSourceBuilder(); + searchSource.query(QueryBuilders.termsQuery("category", "electronics").valueType( + org.opensearch.index.query.TermsQueryBuilder.ValueType.BITMAP)); + + RuntimeException exception = expectThrows(RuntimeException.class, () -> convertDsl(searchSource, indexName)); + assertThat(exception.getMessage(), containsString("does not support non-default value_type")); + } + /** * Test: Range query conversion. * Verifies that a range query is converted to a LogicalFilter with comparison operators. diff --git a/plugins/query-dsl-calcite/src/main/java/org/opensearch/dsl/query/QueryRegistryFactory.java b/plugins/query-dsl-calcite/src/main/java/org/opensearch/dsl/query/QueryRegistryFactory.java index 9e48b60dcead1..ca184f3903bca 100644 --- a/plugins/query-dsl-calcite/src/main/java/org/opensearch/dsl/query/QueryRegistryFactory.java +++ b/plugins/query-dsl-calcite/src/main/java/org/opensearch/dsl/query/QueryRegistryFactory.java @@ -19,6 +19,7 @@ private QueryRegistryFactory() {} public static QueryRegistry create() { QueryRegistry registry = new QueryRegistry(); registry.register(new TermQueryTranslator()); + registry.register(new TermsQueryTranslator()); registry.register(new RangeQueryTranslator()); registry.register(new MatchAllQueryTranslator()); registry.register(new BoolQueryTranslator(registry)); diff --git a/plugins/query-dsl-calcite/src/main/java/org/opensearch/dsl/query/TermsQueryTranslator.java b/plugins/query-dsl-calcite/src/main/java/org/opensearch/dsl/query/TermsQueryTranslator.java new file mode 100644 index 0000000000000..304b240ee7ec6 --- /dev/null +++ b/plugins/query-dsl-calcite/src/main/java/org/opensearch/dsl/query/TermsQueryTranslator.java @@ -0,0 +1,68 @@ +/* + * 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.rel.type.RelDataTypeField; +import org.apache.calcite.rex.RexNode; +import org.apache.calcite.sql.fun.SqlStdOperatorTable; +import org.opensearch.dsl.exception.ConversionException; +import org.opensearch.dsl.ConversionContext; +import org.opensearch.index.query.AbstractQueryBuilder; +import org.opensearch.index.query.QueryBuilder; +import org.opensearch.index.query.TermsQueryBuilder; + +import java.util.List; +import java.util.stream.Collectors; + +/** + * Converts a {@link TermsQueryBuilder} to a Calcite IN RexNode. + */ +public class TermsQueryTranslator implements QueryTranslator { + + @Override + public Class getQueryType() { + return TermsQueryBuilder.class; + } + + @Override + public RexNode convert(QueryBuilder query, ConversionContext ctx) throws ConversionException { + ctx.requireOperatorSupported(SqlStdOperatorTable.IN); + + TermsQueryBuilder termsQuery = (TermsQueryBuilder) query; + + if (termsQuery.boost() != AbstractQueryBuilder.DEFAULT_BOOST) { + throw new RuntimeException("Terms query does not support non-default boost"); + } + if (termsQuery.queryName() != null) { + throw new RuntimeException("Terms query does not support _name"); + } + if (termsQuery.valueType() != TermsQueryBuilder.ValueType.DEFAULT) { + throw new RuntimeException("Terms query does not support non-default value_type"); + } + + String fieldName = termsQuery.fieldName(); + List values = termsQuery.values(); + + if (values == null || values.isEmpty()) { + throw new RuntimeException("Terms query must have values"); + } + + RelDataTypeField field = ctx.getRowType().getField(fieldName, false, false); + if (field == null) { + throw new RuntimeException("Field '" + fieldName + "' not found in schema"); + } + + RexNode fieldRef = ctx.getRexBuilder().makeInputRef(field.getType(), field.getIndex()); + List literals = values.stream() + .map(value -> ctx.getRexBuilder().makeLiteral(value, field.getType(), true)) + .collect(Collectors.toList()); + + return ctx.getRexBuilder().makeIn(fieldRef, literals); + } +} From 070f687014278b2efa443bb30f3602c94af387d5 Mon Sep 17 00:00:00 2001 From: Abhishek Som Date: Fri, 27 Mar 2026 03:39:25 +0530 Subject: [PATCH 2/3] Updated documentation for terms query --- plugins/query-dsl-calcite/README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/plugins/query-dsl-calcite/README.md b/plugins/query-dsl-calcite/README.md index d6f49b488b215..829bd21242f70 100644 --- a/plugins/query-dsl-calcite/README.md +++ b/plugins/query-dsl-calcite/README.md @@ -6,7 +6,7 @@ An OpenSearch plugin that converts OpenSearch Query DSL to Apache Calcite logica This plugin integrates with OpenSearch's search pipeline to convert DSL queries into Calcite's logical plan representation. It supports: -- **Query Types**: Term, Range, Bool (must + filter), Match All +- **Query Types**: Term, Terms, Range, Bool (must + filter), Match All - **Aggregations**: Metric (avg, sum, min, max, count) and bucket (terms, multi\_terms) - **Sorting**: Pre-aggregation and post-aggregation sorting with BucketOrder - **Pagination**: Offset and fetch (limit) @@ -64,6 +64,7 @@ RelNode (Calcite Logical Plan) | DSL Query | Calcite Representation | |-----------|---------------------------------------------------------------------------| | `term` | `=($field, value)` — equality filter | +| `terms` | `SEARCH($field, Sarg[value1, value2, ...])` — multi-value equality filter | | `range` (gte, lte, gt, lt) | `AND(>=($field, min), <=($field, max))` — range filter | | `bool` (must + filter) | `AND(condition1, condition2, ...)` — flattened conjunction | | `match_all` | Skipped (boolean literal `TRUE`) | @@ -407,7 +408,7 @@ Current limitations: 1. **Read-only** — converts queries to logical plans but does not execute them 2. **Logging only** — converted plans are logged, not used for query execution -3. **Limited query types** — only `term`, `range`, `bool` (must + filter), and `match_all` +3. **Limited query types** — only `term`, `terms`, `range`, `bool` (must + filter), and `match_all` 4. **Bool query** — `should` and `must_not` clauses are not yet supported 5. **Nested objects** — flattened using dot notation, no true nested query support 6. **Pagination with aggregations** — `from`/`size` is not applied when aggregations are present From 36ea13842a337495dfb123654cfa40b4ac454558 Mon Sep 17 00:00:00 2001 From: Abhishek Som Date: Fri, 27 Mar 2026 03:55:56 +0530 Subject: [PATCH 3/3] Updated test for terms to test generated output contains expected relNode format --- .../dsl/DslLogicalPlanIntegrationIT.java | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/plugins/query-dsl-calcite/src/internalClusterTest/java/org/opensearch/dsl/DslLogicalPlanIntegrationIT.java b/plugins/query-dsl-calcite/src/internalClusterTest/java/org/opensearch/dsl/DslLogicalPlanIntegrationIT.java index e785bf40b7094..1b5b6738c20ce 100644 --- a/plugins/query-dsl-calcite/src/internalClusterTest/java/org/opensearch/dsl/DslLogicalPlanIntegrationIT.java +++ b/plugins/query-dsl-calcite/src/internalClusterTest/java/org/opensearch/dsl/DslLogicalPlanIntegrationIT.java @@ -72,19 +72,19 @@ public void testTermQueryConversion() throws Exception { /** * Test: Terms query conversion. - * Verifies that a terms query is converted to a LogicalFilter with IN condition. + * Verifies that a terms query is converted to a LogicalFilter with SEARCH condition using Sarg. * * DSL Query: * { * "query": { * "terms": { - * "category": ["electronics", "computers", "laptops"] + * "category": ["electronics", "furniture"] * } * } * } * * Expected Calcite Plan: - * LogicalFilter(condition=[IN($0, 'electronics', 'computers', 'laptops')]) + * LogicalFilter(condition=[SEARCH($0, Sarg['electronics':VARCHAR, 'furniture':VARCHAR]:VARCHAR)]) * LogicalTableScan(table=[[test-terms-query]]) */ public void testTermsQueryConversion() throws Exception { @@ -101,10 +101,20 @@ public void testTermsQueryConversion() throws Exception { ensureGreen(indexName); SearchSourceBuilder searchSource = new SearchSourceBuilder(); - searchSource.query(QueryBuilders.termsQuery("category", "electronics", "computers", "laptops")); + searchSource.query(QueryBuilders.termsQuery("category", "electronics", "furniture")); SearchResponse response = convertDsl(searchSource, indexName); assertNotNull("SearchResponse should not be null", response); + + DslLogicalPlanPlugin plugin = getPlugin(DslLogicalPlanPlugin.class); + QueryPlans plans = plugin.getConverterService().convert(searchSource, indexName); + String plan = plans.get(QueryPlans.Type.HITS).get().relNode().toString(); + + assertTrue("Plan should contain LogicalFilter", plan.contains("LogicalFilter")); + assertTrue("Plan should contain SEARCH operator", plan.contains("SEARCH")); + assertTrue("Plan should contain Sarg", plan.contains("Sarg")); + assertTrue("Plan should contain 'electronics'", plan.contains("electronics")); + assertTrue("Plan should contain 'furniture'", plan.contains("furniture")); } /**