diff --git a/sandbox/libs/analytics-framework/build.gradle b/sandbox/libs/analytics-framework/build.gradle index 0948b6bd4d01b..04b32f6ae65ef 100644 --- a/sandbox/libs/analytics-framework/build.gradle +++ b/sandbox/libs/analytics-framework/build.gradle @@ -30,6 +30,7 @@ dependencies { runtimeOnly 'com.google.guava:failureaccess:1.0.2' // SLF4J — Calcite's logging facade runtimeOnly "org.slf4j:slf4j-api:${versions.slf4j}" + runtimeOnly "commons-io:commons-io:${versions.commonsio}" // Calcite optional deps required at runtime — BuiltInMethod. reflectively loads ALL // methods which triggers class loading for every type referenced in Calcite's SqlFunctions. diff --git a/sandbox/plugins/dsl-query-executor/README.md b/sandbox/plugins/dsl-query-executor/README.md new file mode 100644 index 0000000000000..81228148044ee --- /dev/null +++ b/sandbox/plugins/dsl-query-executor/README.md @@ -0,0 +1,35 @@ +# dsl-query-executor + +A front-end sandbox plugin to the analytics engine that intercepts `_search` requests, converts DSL queries into Calcite RelNode logical plans, and executes them through the analytics engine's query pipeline. + +## Architecture + +``` +_search request + → SearchActionFilter (intercepts SearchAction) + → TransportDslExecuteAction (resolves index, orchestrates pipeline) + → SearchSourceConverter (DSL → Calcite RelNode) + → DslQueryPlanExecutor (delegates to analytics engine) + → SearchResponseBuilder (builds SearchResponse) +``` + +## Dependencies + +- `analytics-engine` — provides `QueryPlanExecutor` and `EngineContext` via Guice (declared as `extendedPlugins`) +- `analytics-framework` — provides Calcite and shared SPI interfaces + +## Running locally + +```bash +./gradlew run -PinstalledPlugins="['analytics-engine','dsl-query-executor']" +``` + +## Testing + +```bash +# Unit tests +./gradlew :sandbox:plugins:dsl-query-executor:test + +# Integration tests +./gradlew :sandbox:plugins:dsl-query-executor:internalClusterTest +``` diff --git a/sandbox/plugins/dsl-query-executor/build.gradle b/sandbox/plugins/dsl-query-executor/build.gradle new file mode 100644 index 0000000000000..5900bbf69e7b9 --- /dev/null +++ b/sandbox/plugins/dsl-query-executor/build.gradle @@ -0,0 +1,28 @@ +/* + * 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. + */ + +apply plugin: 'opensearch.internal-cluster-test' + +opensearchplugin { + description = 'Sandbox plugin for DSL query execution via Calcite logical plans.' + classname = 'org.opensearch.dsl.DslQueryExecutorPlugin' + extendedPlugins = ['analytics-engine'] +} + +dependencies { + compileOnly project(':server') + compileOnly project(':sandbox:libs:analytics-framework') + compileOnly project(':sandbox:plugins:analytics-engine') + compileOnly 'org.apache.calcite.avatica:avatica-core:1.27.0' + + testImplementation project(':test:framework') + testImplementation "org.mockito:mockito-core:${versions.mockito}" + + internalClusterTestImplementation project(':server') + internalClusterTestImplementation project(':test:framework') +} diff --git a/sandbox/plugins/dsl-query-executor/src/internalClusterTest/java/org/opensearch/dsl/DslAggregationIT.java b/sandbox/plugins/dsl-query-executor/src/internalClusterTest/java/org/opensearch/dsl/DslAggregationIT.java new file mode 100644 index 0000000000000..d49d52f9b6af4 --- /dev/null +++ b/sandbox/plugins/dsl-query-executor/src/internalClusterTest/java/org/opensearch/dsl/DslAggregationIT.java @@ -0,0 +1,76 @@ +/* + * 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.opensearch.search.aggregations.AggregationBuilders; +import org.opensearch.search.aggregations.bucket.terms.TermsAggregationBuilder; +import org.opensearch.search.builder.SearchSourceBuilder; + +/** + * Integration tests for DSL aggregation conversion. + * Uses matchAllQuery; focus is on aggregation plan building. + */ +public class DslAggregationIT extends DslIntegTestBase { + + public void testMetricOnly() { + createTestIndex(); + assertOk(search(new SearchSourceBuilder() + .size(0) + .aggregation(AggregationBuilders.avg("avg_price").field("price")) + )); + } + + public void testMultipleMetrics() { + createTestIndex(); + assertOk(search(new SearchSourceBuilder() + .size(0) + .aggregation(AggregationBuilders.avg("avg_price").field("price")) + .aggregation(AggregationBuilders.sum("total_price").field("price")) + .aggregation(AggregationBuilders.min("min_price").field("price")) + .aggregation(AggregationBuilders.max("max_price").field("price")) + )); + } + + public void testTermsBucket() { + createTestIndex(); + assertOk(search(new SearchSourceBuilder() + .size(0) + .aggregation(new TermsAggregationBuilder("by_brand").field("brand")) + )); + } + + public void testTermsBucketWithMetric() { + createTestIndex(); + assertOk(search(new SearchSourceBuilder() + .size(0) + .aggregation(new TermsAggregationBuilder("by_brand").field("brand") + .subAggregation(AggregationBuilders.avg("avg_price").field("price"))) + )); + } + + public void testNestedBuckets() { + createTestIndex(); + assertOk(search(new SearchSourceBuilder() + .size(0) + .aggregation(new TermsAggregationBuilder("by_brand").field("brand") + .subAggregation(AggregationBuilders.sum("total").field("price")) + .subAggregation(new TermsAggregationBuilder("by_name").field("name") + .subAggregation(AggregationBuilders.avg("avg_price").field("price")))) + )); + } + + public void testAggsWithHits() { + createTestIndex(); + // size > 0 with aggs produces both HITS + AGGREGATION plans + assertOk(search(new SearchSourceBuilder() + .size(10) + .aggregation(AggregationBuilders.avg("avg_price").field("price")) + )); + } +} diff --git a/sandbox/plugins/dsl-query-executor/src/internalClusterTest/java/org/opensearch/dsl/DslIntegTestBase.java b/sandbox/plugins/dsl-query-executor/src/internalClusterTest/java/org/opensearch/dsl/DslIntegTestBase.java new file mode 100644 index 0000000000000..f502fcb5f5635 --- /dev/null +++ b/sandbox/plugins/dsl-query-executor/src/internalClusterTest/java/org/opensearch/dsl/DslIntegTestBase.java @@ -0,0 +1,56 @@ +/* + * 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.opensearch.action.search.SearchRequest; +import org.opensearch.action.search.SearchResponse; +import org.opensearch.analytics.AnalyticsPlugin; +import org.opensearch.common.xcontent.XContentType; +import org.opensearch.plugins.Plugin; +import org.opensearch.search.builder.SearchSourceBuilder; +import org.opensearch.test.OpenSearchIntegTestCase; + +import java.util.Collection; +import java.util.List; + +// TODO: once end-to-end execution returns real results, update ITs to verify +// actual hit count, field values, sort order, and aggregation buckets. +/** + * Base class for DSL query executor integration tests. + * Provides shared index setup and search helper. + */ +@OpenSearchIntegTestCase.ClusterScope(scope = OpenSearchIntegTestCase.Scope.SUITE, numDataNodes = 1) +public abstract class DslIntegTestBase extends OpenSearchIntegTestCase { + + protected static final String INDEX = "test-index"; + + @Override + protected Collection> nodePlugins() { + return List.of(AnalyticsPlugin.class, DslQueryExecutorPlugin.class); + } + + protected void createTestIndex() { + createIndex(INDEX); + ensureGreen(); + client().prepareIndex(INDEX) + .setId("1") + .setSource("{\"name\":\"laptop\",\"price\":1200,\"brand\":\"brandX\",\"rating\":4.5}", XContentType.JSON) + .get(); + refresh(INDEX); + } + + protected SearchResponse search(SearchSourceBuilder source) { + return client().search(new SearchRequest(INDEX).source(source)).actionGet(); + } + + protected void assertOk(SearchResponse response) { + assertNotNull(response); + assertEquals(200, response.status().getStatus()); + } +} diff --git a/sandbox/plugins/dsl-query-executor/src/internalClusterTest/java/org/opensearch/dsl/DslProjectIT.java b/sandbox/plugins/dsl-query-executor/src/internalClusterTest/java/org/opensearch/dsl/DslProjectIT.java new file mode 100644 index 0000000000000..0cd3de16ea566 --- /dev/null +++ b/sandbox/plugins/dsl-query-executor/src/internalClusterTest/java/org/opensearch/dsl/DslProjectIT.java @@ -0,0 +1,49 @@ +/* + * 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.opensearch.search.builder.SearchSourceBuilder; +import org.opensearch.search.fetch.subphase.FetchSourceContext; + +/** + * Integration tests for DSL _source filtering (projection) conversion. + * Uses matchAllQuery; focus is on _source includes/excludes behavior. + */ +public class DslProjectIT extends DslIntegTestBase { + + public void testNoSourceFiltering() { + createTestIndex(); + assertOk(search(new SearchSourceBuilder())); + } + + public void testIncludeSpecificFields() { + createTestIndex(); + assertOk(search(new SearchSourceBuilder().fetchSource(new String[]{"name", "price"}, null))); + } + + public void testExcludeFields() { + createTestIndex(); + assertOk(search(new SearchSourceBuilder().fetchSource(new String[]{}, new String[]{"rating"}))); + } + + public void testSourceDisabled() { + createTestIndex(); + assertOk(search(new SearchSourceBuilder().fetchSource(false))); + } + + public void testWildcardIncludes() { + createTestIndex(); + assertOk(search(new SearchSourceBuilder().fetchSource(new String[]{"na*"}, null))); + } + + public void testWildcardExcludes() { + createTestIndex(); + assertOk(search(new SearchSourceBuilder().fetchSource(new String[]{}, new String[]{"ra*"}))); + } +} diff --git a/sandbox/plugins/dsl-query-executor/src/internalClusterTest/java/org/opensearch/dsl/DslQueryIT.java b/sandbox/plugins/dsl-query-executor/src/internalClusterTest/java/org/opensearch/dsl/DslQueryIT.java new file mode 100644 index 0000000000000..8b7ed06acaf47 --- /dev/null +++ b/sandbox/plugins/dsl-query-executor/src/internalClusterTest/java/org/opensearch/dsl/DslQueryIT.java @@ -0,0 +1,57 @@ +/* + * 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.opensearch.action.search.SearchRequest; +import org.opensearch.index.query.QueryBuilders; +import org.opensearch.search.builder.SearchSourceBuilder; + +/** + * Integration tests for DSL query conversion (filter path). + * Uses various query types; sort and projection use defaults. + */ +public class DslQueryIT extends DslIntegTestBase { + + public void testNoQuery() { + createTestIndex(); + assertOk(search(new SearchSourceBuilder())); + } + + public void testMatchAll() { + createTestIndex(); + assertOk(search(new SearchSourceBuilder().query(QueryBuilders.matchAllQuery()))); + } + + public void testTermQuery() { + createTestIndex(); + assertOk(search(new SearchSourceBuilder().query(QueryBuilders.termQuery("name", "laptop")))); + } + + public void testWildcardQueryWithUnresolvedNode() { + createTestIndex(); + // Wildcard query is not converted to standard Rex — wraps in UnresolvedQueryCall. + assertOk(search(new SearchSourceBuilder().query(QueryBuilders.wildcardQuery("name", "lap*")))); + } + + public void testFailsForNonexistentIndex() { + expectThrows(Exception.class, () -> + client().search(new SearchRequest("nonexistent-index").source(new SearchSourceBuilder())).actionGet() + ); + } + + public void testFailsForMultipleIndices() { + createTestIndex(); + createIndex("test-index-2"); + ensureGreen(); + + expectThrows(Exception.class, () -> + client().search(new SearchRequest(INDEX, "test-index-2").source(new SearchSourceBuilder())).actionGet() + ); + } +} diff --git a/sandbox/plugins/dsl-query-executor/src/internalClusterTest/java/org/opensearch/dsl/DslSortIT.java b/sandbox/plugins/dsl-query-executor/src/internalClusterTest/java/org/opensearch/dsl/DslSortIT.java new file mode 100644 index 0000000000000..41de1095330d2 --- /dev/null +++ b/sandbox/plugins/dsl-query-executor/src/internalClusterTest/java/org/opensearch/dsl/DslSortIT.java @@ -0,0 +1,58 @@ +/* + * 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.opensearch.search.builder.SearchSourceBuilder; +import org.opensearch.search.sort.SortOrder; + +/** + * Integration tests for DSL sort and pagination conversion. + * Uses matchAllQuery; focus is on sort/from/size behavior. + */ +public class DslSortIT extends DslIntegTestBase { + + public void testDefaultPagination() { + createTestIndex(); + assertOk(search(new SearchSourceBuilder())); + } + + public void testSortAscending() { + createTestIndex(); + assertOk(search(new SearchSourceBuilder().sort("name", SortOrder.ASC))); + } + + public void testSortDescending() { + createTestIndex(); + assertOk(search(new SearchSourceBuilder().sort("price", SortOrder.DESC))); + } + + public void testMultipleSortFields() { + createTestIndex(); + assertOk(search( + new SearchSourceBuilder() + .sort("brand", SortOrder.ASC) + .sort("price", SortOrder.DESC) + )); + } + + public void testCustomSize() { + createTestIndex(); + assertOk(search(new SearchSourceBuilder().size(5))); + } + + public void testFromAndSize() { + createTestIndex(); + assertOk(search(new SearchSourceBuilder().from(0).size(5))); + } + + public void testFromOffset() { + createTestIndex(); + assertOk(search(new SearchSourceBuilder().from(10).size(5))); + } +} diff --git a/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/DslQueryExecutorPlugin.java b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/DslQueryExecutorPlugin.java new file mode 100644 index 0000000000000..8964835e36017 --- /dev/null +++ b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/DslQueryExecutorPlugin.java @@ -0,0 +1,75 @@ +/* + * 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.opensearch.action.ActionRequest; +import org.opensearch.action.support.ActionFilter; +import org.opensearch.cluster.metadata.IndexNameExpressionResolver; +import org.opensearch.cluster.service.ClusterService; +import org.opensearch.core.action.ActionResponse; +import org.opensearch.core.common.io.stream.NamedWriteableRegistry; +import org.opensearch.core.xcontent.NamedXContentRegistry; +import org.opensearch.dsl.action.DslExecuteAction; +import org.opensearch.dsl.action.SearchActionFilter; +import org.opensearch.dsl.action.TransportDslExecuteAction; +import org.opensearch.env.Environment; +import org.opensearch.env.NodeEnvironment; +import org.opensearch.plugins.ActionPlugin; +import org.opensearch.plugins.Plugin; +import org.opensearch.repositories.RepositoriesService; +import org.opensearch.script.ScriptService; +import org.opensearch.threadpool.ThreadPool; +import org.opensearch.transport.client.Client; +import org.opensearch.transport.client.node.NodeClient; +import org.opensearch.watcher.ResourceWatcherService; + +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.function.Supplier; + +/** + * Plugin entry point. Registers {@link SearchActionFilter} to intercept _search requests + * and {@link TransportDslExecuteAction} to handle DSL-to-Calcite conversion and execution. + */ +public class DslQueryExecutorPlugin extends Plugin implements ActionPlugin { + + private SearchActionFilter searchActionFilter; + + /** Creates a new plugin instance. */ + public DslQueryExecutorPlugin() {} + + @Override + public Collection createComponents( + Client client, + ClusterService clusterService, + ThreadPool threadPool, + ResourceWatcherService resourceWatcherService, + ScriptService scriptService, + NamedXContentRegistry xContentRegistry, + Environment environment, + NodeEnvironment nodeEnvironment, + NamedWriteableRegistry namedWriteableRegistry, + IndexNameExpressionResolver indexNameExpressionResolver, + Supplier repositoriesServiceSupplier + ) { + this.searchActionFilter = new SearchActionFilter((NodeClient) client); + return Collections.emptyList(); + } + + @Override + public List> getActions() { + return List.of(new ActionHandler<>(DslExecuteAction.INSTANCE, TransportDslExecuteAction.class)); + } + + @Override + public List getActionFilters() { + return searchActionFilter != null ? List.of(searchActionFilter) : List.of(); + } +} diff --git a/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/action/DslExecuteAction.java b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/action/DslExecuteAction.java new file mode 100644 index 0000000000000..0a85565282715 --- /dev/null +++ b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/action/DslExecuteAction.java @@ -0,0 +1,28 @@ +/* + * 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.action; + +import org.opensearch.action.ActionType; +import org.opensearch.action.search.SearchResponse; + +/** + * Internal action for executing DSL queries through the analytics engine. + * Accepts a {@link org.opensearch.action.search.SearchRequest} and returns a {@link SearchResponse}. + */ +public class DslExecuteAction extends ActionType { + + /** Action name registered with the transport layer. */ + public static final String NAME = "cluster:internal/dsl/execute"; + /** Singleton instance. */ + public static final DslExecuteAction INSTANCE = new DslExecuteAction(); + + private DslExecuteAction() { + super(NAME, SearchResponse::new); + } +} diff --git a/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/action/SearchActionFilter.java b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/action/SearchActionFilter.java new file mode 100644 index 0000000000000..23cb2441c9e13 --- /dev/null +++ b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/action/SearchActionFilter.java @@ -0,0 +1,60 @@ +/* + * 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.action; + +import org.opensearch.action.ActionRequest; +import org.opensearch.action.search.SearchAction; +import org.opensearch.action.search.SearchRequest; +import org.opensearch.action.search.SearchResponse; +import org.opensearch.action.support.ActionFilter; +import org.opensearch.action.support.ActionFilterChain; +import org.opensearch.core.action.ActionListener; +import org.opensearch.core.action.ActionResponse; +import org.opensearch.tasks.Task; +import org.opensearch.transport.client.node.NodeClient; + +/** + * Intercepts all {@code _search} requests and dispatches them to {@link DslExecuteAction} + * for execution through the Calcite pipeline. Non-search actions pass through unchanged. + */ +public class SearchActionFilter implements ActionFilter { + + private final NodeClient client; + + /** + * Creates a filter that dispatches intercepted searches via the given client. + * + * @param client node client for dispatching to {@link DslExecuteAction} + */ + public SearchActionFilter(NodeClient client) { + this.client = client; + } + + @Override + public int order() { + return Integer.MIN_VALUE; + } + + @Override + @SuppressWarnings("unchecked") + public void apply( + Task task, + String action, + Request request, + ActionListener listener, + ActionFilterChain chain + ) { + if (SearchAction.NAME.equals(action)) { + SearchRequest searchRequest = (SearchRequest) request; + client.execute(DslExecuteAction.INSTANCE, searchRequest, (ActionListener) listener); + } else { + chain.proceed(task, action, request, listener); + } + } +} diff --git a/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/action/TransportDslExecuteAction.java b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/action/TransportDslExecuteAction.java new file mode 100644 index 0000000000000..cce520f132241 --- /dev/null +++ b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/action/TransportDslExecuteAction.java @@ -0,0 +1,118 @@ +/* + * 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.action; + +import org.apache.calcite.rel.RelNode; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.opensearch.action.search.SearchRequest; +import org.opensearch.action.search.SearchResponse; +import org.opensearch.action.support.ActionFilters; +import org.opensearch.action.support.HandledTransportAction; +import org.opensearch.analytics.EngineContext; +import org.opensearch.analytics.exec.QueryPlanExecutor; +import org.opensearch.cluster.metadata.IndexNameExpressionResolver; +import org.opensearch.cluster.service.ClusterService; +import org.opensearch.common.inject.Inject; +import org.opensearch.core.action.ActionListener; +import org.opensearch.core.index.Index; +import org.opensearch.dsl.converter.SearchSourceConverter; +import org.opensearch.dsl.executor.DslQueryPlanExecutor; +import org.opensearch.dsl.executor.QueryPlans; +import org.opensearch.dsl.result.ExecutionResult; +import org.opensearch.dsl.result.SearchResponseBuilder; +import org.opensearch.tasks.Task; +import org.opensearch.transport.TransportService; + +import java.util.List; + +/** + * Coordinates DSL query execution: converts SearchSourceBuilder to Calcite RelNode plans, + * executes them via the analytics engine, and builds a SearchResponse. + * + *

Receives {@link QueryPlanExecutor} and {@link EngineContext} from the analytics engine + * via Guice injection (enabled by {@code extendedPlugins = ['analytics-engine']}). + */ +public class TransportDslExecuteAction extends HandledTransportAction { + + private static final Logger logger = LogManager.getLogger(TransportDslExecuteAction.class); + + private final EngineContext engineContext; + private final DslQueryPlanExecutor planExecutor; + private final ClusterService clusterService; + private final IndexNameExpressionResolver indexNameExpressionResolver; + + /** + * Guice-injected constructor — receives analytics engine dependencies. + * + * @param transportService transport service + * @param actionFilters action filters + * @param engineContext analytics engine context providing schema and operator table + * @param executor analytics engine plan executor + * @param clusterService cluster service for resolving index aliases + * @param indexNameExpressionResolver resolves aliases and wildcards to concrete indices + */ + @Inject + public TransportDslExecuteAction( + TransportService transportService, + ActionFilters actionFilters, + EngineContext engineContext, + QueryPlanExecutor> executor, + ClusterService clusterService, + IndexNameExpressionResolver indexNameExpressionResolver + ) { + super(DslExecuteAction.NAME, transportService, actionFilters, SearchRequest::new); + this.engineContext = engineContext; + this.planExecutor = new DslQueryPlanExecutor(executor); + this.clusterService = clusterService; + this.indexNameExpressionResolver = indexNameExpressionResolver; + } + + @Override + protected void doExecute(Task task, SearchRequest request, ActionListener listener) { + try { + String indexName = resolveToSingleIndex(request); + long startTime = System.currentTimeMillis(); + + SearchSourceConverter converter = new SearchSourceConverter(engineContext.getSchema()); + QueryPlans plans = converter.convert(request.source(), indexName); + List results = planExecutor.execute(plans); + long tookInMillis = System.currentTimeMillis() - startTime; + + SearchResponse response = SearchResponseBuilder.build(results, tookInMillis); + listener.onResponse(response); + } catch (Exception e) { + logger.error("DSL execution failed", e); + listener.onFailure(e); + } + } + + // TODO: add multi-index support: + // 1. aliases pointing to multiple indices (e.g. my-alias → [index-a, index-b]) + // 2. comma-separated indices (e.g. GET /index-a,index-b/_search) + // 3. wildcard patterns (e.g. GET /index-*/_search) + /** + * Resolves the request's indices (which may be aliases or wildcards) to a single concrete index. + * Throws if the resolution yields zero or more than one concrete index. + */ + private String resolveToSingleIndex(SearchRequest request) { + Index[] concreteIndices = indexNameExpressionResolver.concreteIndices( + clusterService.state(), + request + ); + if (concreteIndices.length != 1) { + throw new IllegalArgumentException( + "DSL execution currently supports exactly one concrete index, but resolved to " + + concreteIndices.length + + " indices" + ); + } + return concreteIndices[0].getName(); + } +} diff --git a/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/action/package-info.java b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/action/package-info.java new file mode 100644 index 0000000000000..53abc6682e733 --- /dev/null +++ b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/action/package-info.java @@ -0,0 +1,10 @@ +/* + * 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. + */ + +/** Transport action and filter for routing DSL queries through the Calcite pipeline. */ +package org.opensearch.dsl.action; diff --git a/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/aggregation/AggregationMetadata.java b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/aggregation/AggregationMetadata.java new file mode 100644 index 0000000000000..360907308b68a --- /dev/null +++ b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/aggregation/AggregationMetadata.java @@ -0,0 +1,69 @@ +/* + * 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.aggregation; + +import org.apache.calcite.rel.core.AggregateCall; +import org.apache.calcite.util.ImmutableBitSet; + +import java.util.List; + +/** + * Pre-computed metadata for one aggregation granularity level. + * Contains everything needed to build a single {@code LogicalAggregate}. + * + *

A multi-level aggregation tree (e.g., terms → terms → avg) produces + * multiple metadata instances — one per distinct GROUP BY key set. + */ +public class AggregationMetadata { + + private final ImmutableBitSet groupByBitSet; + private final List groupByFieldNames; + private final List aggregateCalls; + private final List aggregateFieldNames; + + /** + * Creates aggregation metadata. + * + * @param groupByBitSet column indices for GROUP BY + * @param groupByFieldNames field names for GROUP BY columns + * @param aggregateCalls Calcite aggregate calls (AVG, SUM, etc.) + * @param aggregateFieldNames output names for aggregate results + */ + public AggregationMetadata( + ImmutableBitSet groupByBitSet, + List groupByFieldNames, + List aggregateCalls, + List aggregateFieldNames + ) { + this.groupByBitSet = groupByBitSet; + this.groupByFieldNames = List.copyOf(groupByFieldNames); + this.aggregateCalls = List.copyOf(aggregateCalls); + this.aggregateFieldNames = List.copyOf(aggregateFieldNames); + } + + /** Returns the GROUP BY column indices. */ + public ImmutableBitSet getGroupByBitSet() { + return groupByBitSet; + } + + /** Returns the GROUP BY field names. */ + public List getGroupByFieldNames() { + return groupByFieldNames; + } + + /** Returns the aggregate calls. */ + public List getAggregateCalls() { + return aggregateCalls; + } + + /** Returns the output field names for aggregate results. */ + public List getAggregateFieldNames() { + return aggregateFieldNames; + } +} diff --git a/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/aggregation/AggregationMetadataBuilder.java b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/aggregation/AggregationMetadataBuilder.java new file mode 100644 index 0000000000000..37ad8888f5eb9 --- /dev/null +++ b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/aggregation/AggregationMetadataBuilder.java @@ -0,0 +1,132 @@ +/* + * 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.aggregation; + +import org.apache.calcite.rel.RelCollations; +import org.apache.calcite.rel.core.AggregateCall; +import org.apache.calcite.rel.type.RelDataType; +import org.apache.calcite.rel.type.RelDataTypeFactory; +import org.apache.calcite.sql.fun.SqlStdOperatorTable; +import org.apache.calcite.sql.type.SqlTypeName; +import org.apache.calcite.util.ImmutableBitSet; +import org.opensearch.dsl.converter.ConversionException; + +import java.util.ArrayList; +import java.util.List; + +/** + * Mutable builder for {@link AggregationMetadata}. Used by {@link AggregationTreeWalker} + * to accumulate groupings and aggregate calls during tree traversal. + * Grouping indices are resolved at build time from the input row type. + */ +public class AggregationMetadataBuilder { + + /** Name used for the implicit COUNT(*) aggregate added by bucket aggregations. */ + public static final String IMPLICIT_COUNT_NAME = "_count"; + + private final List groupings = new ArrayList<>(); + private final List aggregateCalls = new ArrayList<>(); + private final List aggregateFieldNames = new ArrayList<>(); + private boolean implicitCountRequested = false; + + /** Creates a new empty builder. */ + public AggregationMetadataBuilder() {} + + /** + * Adds a grouping contribution from a bucket translator. + * + * @param grouping the grouping info + */ + public void addGrouping(GroupingInfo grouping) { + groupings.add(grouping); + } + + /** + * Adds an aggregate call with its output field name. + * + * @param call the Calcite aggregate call + * @param fieldName the output field name + */ + public void addAggregateCall(AggregateCall call, String fieldName) { + aggregateCalls.add(call); + aggregateFieldNames.add(fieldName); + } + + /** + * Requests an implicit COUNT(*) for bucket doc_count. + * Idempotent — only one COUNT(*) is created at build time. + */ + public void requestImplicitCount() { + this.implicitCountRequested = true; + } + + /** Returns true if this builder has at least one aggregate call or implicit count. */ + public boolean hasAggregateCalls() { + return !aggregateCalls.isEmpty() || implicitCountRequested; + } + + /** + * Builds the immutable metadata. Resolves grouping indices from the input row type. + * For no-GROUP-BY metrics, makes return types nullable (AVG of empty set is null). + * + * @param inputRowType the schema before aggregation + * @param typeFactory the type factory for creating types + * @return the aggregation metadata + * @throws ConversionException if field resolution fails + */ + public AggregationMetadata build(RelDataType inputRowType, RelDataTypeFactory typeFactory) throws ConversionException { + // Resolve grouping indices at build time + List allGroupIndices = new ArrayList<>(); + List allGroupFieldNames = new ArrayList<>(); + for (GroupingInfo g : groupings) { + allGroupIndices.addAll(g.resolveIndices(inputRowType)); + allGroupFieldNames.addAll(g.getFieldNames()); + } + + // For no-GROUP-BY, metric results could be null (e.g., AVG of empty set). + // COUNT stays non-nullable (returns 0). + boolean noGroupBy = groupings.isEmpty(); + List allCalls = new ArrayList<>(); + for (AggregateCall call : aggregateCalls) { + if (noGroupBy) { + RelDataType nullableType = typeFactory.createTypeWithNullability(call.getType(), true); + allCalls.add(AggregateCall.create( + call.getAggregation(), call.isDistinct(), call.isApproximate(), + call.ignoreNulls(), call.getArgList(), call.filterArg, + call.getCollation(), nullableType, call.getName() + )); + } else { + allCalls.add(call); + } + } + List allFieldNames = new ArrayList<>(aggregateFieldNames); + + if (implicitCountRequested) { + allCalls.add(AggregateCall.create( + SqlStdOperatorTable.COUNT, + false, + false, + false, + List.of(), + -1, + RelCollations.EMPTY, + typeFactory.createSqlType(SqlTypeName.BIGINT), + IMPLICIT_COUNT_NAME + )); + allFieldNames.add(IMPLICIT_COUNT_NAME); + } + + return new AggregationMetadata( + ImmutableBitSet.of(allGroupIndices), + allGroupFieldNames, + allCalls, + allFieldNames + ); + } +} diff --git a/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/aggregation/AggregationRegistry.java b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/aggregation/AggregationRegistry.java new file mode 100644 index 0000000000000..3def20fe0c637 --- /dev/null +++ b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/aggregation/AggregationRegistry.java @@ -0,0 +1,73 @@ +/* + * 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.aggregation; + +import org.opensearch.dsl.aggregation.bucket.BucketTranslator; +import org.opensearch.dsl.aggregation.metric.MetricTranslator; +import org.opensearch.search.aggregations.AggregationBuilder; + +import java.util.HashMap; +import java.util.Map; + +/** + * Registry of all aggregation translators — both metric and bucket. + * Single map keyed by AggregationBuilder class, O(1) lookup. + * Callers use {@code instanceof} to distinguish metric from bucket. + */ +public class AggregationRegistry { + + private final Map, AggregationType> translators = new HashMap<>(); + + /** Creates an empty registry. */ + public AggregationRegistry() {} + + /** + * Registers a translator (metric or bucket). + * + * @param translator the translator to register + */ + public void register(AggregationType translator) { + translators.put(translator.getAggregationType(), translator); + } + + /** + * Returns the translator for the given class, or null. + * Caller checks {@code instanceof MetricTranslator} or {@code instanceof BucketTranslator}. + * + * @param aggClass the aggregation builder class + * @return the translator, or null + */ + public AggregationType get(Class aggClass) { + return translators.get(aggClass); + } + + /** + * Returns the metric translator for the given class, or null. + * + * @param aggClass the aggregation builder class + * @return the metric translator, or null + */ + @SuppressWarnings("unchecked") + public MetricTranslator getMetric(Class aggClass) { + AggregationType translator = translators.get(aggClass); + return translator instanceof MetricTranslator ? (MetricTranslator) translator : null; + } + + /** + * Returns the bucket translator for the given class, or null. + * + * @param aggClass the aggregation builder class + * @return the bucket translator, or null + */ + @SuppressWarnings("unchecked") + public BucketTranslator getBucket(Class aggClass) { + AggregationType translator = translators.get(aggClass); + return translator instanceof BucketTranslator ? (BucketTranslator) translator : null; + } +} diff --git a/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/aggregation/AggregationRegistryFactory.java b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/aggregation/AggregationRegistryFactory.java new file mode 100644 index 0000000000000..9297f6ca5cefd --- /dev/null +++ b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/aggregation/AggregationRegistryFactory.java @@ -0,0 +1,35 @@ +/* + * 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.aggregation; + +import org.opensearch.dsl.aggregation.bucket.TermsBucketTranslator; +import org.opensearch.dsl.aggregation.metric.AvgMetricTranslator; +import org.opensearch.dsl.aggregation.metric.MaxMetricTranslator; +import org.opensearch.dsl.aggregation.metric.MinMetricTranslator; +import org.opensearch.dsl.aggregation.metric.SumMetricTranslator; + +/** + * Creates an {@link AggregationRegistry} populated with all supported translators. + */ +public class AggregationRegistryFactory { + + private AggregationRegistryFactory() {} + + /** Creates a registry with all supported metric and bucket translators. */ + public static AggregationRegistry create() { + AggregationRegistry registry = new AggregationRegistry(); + registry.register(new AvgMetricTranslator()); + registry.register(new SumMetricTranslator()); + registry.register(new MinMetricTranslator()); + registry.register(new MaxMetricTranslator()); + registry.register(new TermsBucketTranslator()); + // TODO: add other aggregation translators + return registry; + } +} diff --git a/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/aggregation/AggregationTreeWalker.java b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/aggregation/AggregationTreeWalker.java new file mode 100644 index 0000000000000..2b7dec34a76fa --- /dev/null +++ b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/aggregation/AggregationTreeWalker.java @@ -0,0 +1,157 @@ +/* + * 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.aggregation; + +import org.apache.calcite.rel.type.RelDataType; +import org.apache.calcite.rel.type.RelDataTypeFactory; +import org.opensearch.dsl.aggregation.bucket.BucketTranslator; +import org.opensearch.dsl.aggregation.metric.MetricTranslator; +import org.opensearch.dsl.converter.ConversionException; +import org.opensearch.search.aggregations.AggregationBuilder; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * Recursively walks the DSL aggregation tree and produces one {@link AggregationMetadata} + * per distinct granularity level. + * + *

A "granularity" is a unique GROUP BY key set determined by the accumulated bucket + * nesting path. Metrics at different nesting depths produce separate metadata instances, + * each yielding its own {@code LogicalAggregate} and {@code QueryPlan}. + */ +public class AggregationTreeWalker { + + private final AggregationRegistry registry; + + /** + * Creates a tree walker. + * + * @param registry the aggregation registry for looking up translators + */ + public AggregationTreeWalker(AggregationRegistry registry) { + this.registry = registry; + } + + /** + * Walks the aggregation tree and returns one AggregationMetadata per granularity level. + * + * @param aggs the top-level aggregation builders + * @param rowType the input row type for field resolution + * @param typeFactory the type factory for creating aggregate return types + * @return metadata list, one per granularity (only levels with metrics or implicit count) + * @throws ConversionException if any aggregation fails to convert + */ + public List walk( + Collection aggs, + RelDataType rowType, + RelDataTypeFactory typeFactory + ) throws ConversionException { + Map granularities = new LinkedHashMap<>(); + walkRecursive(aggs, new ArrayList<>(), granularities, rowType); + + List result = new ArrayList<>(); + for (AggregationMetadataBuilder builder : granularities.values()) { + if (builder.hasAggregateCalls()) { + result.add(builder.build(rowType, typeFactory)); + } + } + return result; + } + + @SuppressWarnings("unchecked") + private void walkRecursive( + Collection aggs, + List currentGroupings, + Map granularities, + RelDataType rowType + ) throws ConversionException { + for (AggregationBuilder aggBuilder : aggs) { + AggregationType type = registry.get(aggBuilder.getClass()); + + if (type instanceof BucketTranslator) { + handleBucket((BucketTranslator) type, aggBuilder, currentGroupings, granularities, rowType); + } else if (type instanceof MetricTranslator) { + handleMetric((MetricTranslator) type, aggBuilder, currentGroupings, granularities, rowType); + } else { + throw new ConversionException("Unsupported aggregation type: " + aggBuilder.getClass().getSimpleName()); + } + } + } + + private void handleBucket( + BucketTranslator translator, + AggregationBuilder aggBuilder, + List currentGroupings, + Map granularities, + RelDataType rowType + ) throws ConversionException { + GroupingInfo grouping = translator.getGrouping(aggBuilder); + + List accumulatedGroupings = new ArrayList<>(currentGroupings); + accumulatedGroupings.add(grouping); + + // Ensure builder exists for this granularity + getOrCreateBuilder(accumulatedGroupings, granularities); + + // Recurse into sub-aggregations + Collection subAggs = translator.getSubAggregations(aggBuilder); + if (subAggs != null && !subAggs.isEmpty()) { + walkRecursive(subAggs, accumulatedGroupings, granularities, rowType); + } + } + + private void handleMetric( + MetricTranslator translator, + AggregationBuilder aggBuilder, + List currentGroupings, + Map granularities, + RelDataType rowType + ) throws ConversionException { + AggregationMetadataBuilder builder = getOrCreateBuilder(currentGroupings, granularities); + builder.addAggregateCall( + translator.toAggregateCall(aggBuilder, rowType), + translator.getAggregateFieldName(aggBuilder) + ); + } + + private AggregationMetadataBuilder getOrCreateBuilder( + List groupings, + Map granularities + ) { + String key = granularityKey(groupings); + AggregationMetadataBuilder existing = granularities.get(key); + if (existing != null) { + return existing; + } + + AggregationMetadataBuilder builder = new AggregationMetadataBuilder(); + for (GroupingInfo g : groupings) { + builder.addGrouping(g); + } + if (!groupings.isEmpty()) { + builder.requestImplicitCount(); + } + granularities.put(key, builder); + return builder; + } + + private static String granularityKey(List groupings) { + if (groupings.isEmpty()) { + return ""; + } + return groupings.stream() + .flatMap(g -> g.getFieldNames().stream()) + .collect(Collectors.joining(",")); + } +} diff --git a/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/aggregation/AggregationType.java b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/aggregation/AggregationType.java new file mode 100644 index 0000000000000..0b4b17a2de742 --- /dev/null +++ b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/aggregation/AggregationType.java @@ -0,0 +1,22 @@ +/* + * 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.aggregation; + +import org.opensearch.search.aggregations.AggregationBuilder; + +/** + * Base type interface for aggregation translators. + * Provides type identification for the {@link AggregationRegistry}. + * Bucket and metric subtypes define their own contracts. + */ +public interface AggregationType { + + /** Returns the concrete AggregationBuilder class this type handles. */ + Class getAggregationType(); +} diff --git a/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/aggregation/FieldGrouping.java b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/aggregation/FieldGrouping.java new file mode 100644 index 0000000000000..a89fa65060013 --- /dev/null +++ b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/aggregation/FieldGrouping.java @@ -0,0 +1,52 @@ +/* + * 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.aggregation; + +import org.apache.calcite.rel.type.RelDataType; +import org.apache.calcite.rel.type.RelDataTypeField; +import org.opensearch.dsl.converter.ConversionException; + +import java.util.ArrayList; +import java.util.List; + +/** + * Field-based grouping: GROUP BY field1, field2, ... + * Used by terms and multi_terms bucket aggregations. + */ +public class FieldGrouping implements GroupingInfo { + + private final List fieldNames; + + /** + * Creates a field grouping. + * + * @param fieldNames the field names to group by + */ + public FieldGrouping(List fieldNames) { + this.fieldNames = List.copyOf(fieldNames); + } + + @Override + public List getFieldNames() { + return fieldNames; + } + + @Override + public List resolveIndices(RelDataType inputRowType) throws ConversionException { + List indices = new ArrayList<>(fieldNames.size()); + for (String name : fieldNames) { + RelDataTypeField field = inputRowType.getField(name, false, false); + if (field == null) { + throw new ConversionException("Group-by field '" + name + "' not found in schema"); + } + indices.add(field.getIndex()); + } + return indices; + } +} diff --git a/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/aggregation/GroupingInfo.java b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/aggregation/GroupingInfo.java new file mode 100644 index 0000000000000..14d07ab2ac91c --- /dev/null +++ b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/aggregation/GroupingInfo.java @@ -0,0 +1,34 @@ +/* + * 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.aggregation; + +import org.apache.calcite.rel.type.RelDataType; +import org.opensearch.dsl.converter.ConversionException; + +import java.util.List; + +/** + * Represents a grouping contribution from a bucket aggregation. + * Implementations provide field-based grouping (terms) or + * expression-based grouping (histogram, range) without modifying this interface. + */ +public interface GroupingInfo { + + /** Returns the logical field names this grouping contributes. */ + List getFieldNames(); + + /** + * Resolves this grouping to column indices in the input schema. + * + * @param inputRowType the schema before aggregation + * @return column indices for the GROUP BY bit set + * @throws ConversionException if field lookup fails + */ + List resolveIndices(RelDataType inputRowType) throws ConversionException; +} diff --git a/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/aggregation/bucket/BucketTranslator.java b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/aggregation/bucket/BucketTranslator.java new file mode 100644 index 0000000000000..29dda780e7bc1 --- /dev/null +++ b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/aggregation/bucket/BucketTranslator.java @@ -0,0 +1,50 @@ +/* + * 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.aggregation.bucket; + +import org.opensearch.dsl.aggregation.AggregationType; +import org.opensearch.dsl.aggregation.GroupingInfo; +import org.opensearch.dsl.result.BucketEntry; +import org.opensearch.search.aggregations.AggregationBuilder; +import org.opensearch.search.aggregations.InternalAggregation; + +import java.util.Collection; +import java.util.List; + +/** + * Translates a bucket aggregation (terms, multi_terms, etc.) to a {@link GroupingInfo} + * for GROUP BY resolution, and converts results back to InternalAggregation for response building. + */ +public interface BucketTranslator extends AggregationType { + + /** + * Returns the grouping contribution for this bucket. + * + * @param agg the bucket aggregation builder + * @return the grouping info + */ + GroupingInfo getGrouping(T agg); + + /** + * Returns sub-aggregations to recurse into. + * + * @param agg the bucket aggregation builder + * @return the sub-aggregations + */ + Collection getSubAggregations(T agg); + + /** + * Converts grouped bucket entries into an OpenSearch InternalAggregation for response building. + * + * @param agg the original aggregation builder + * @param buckets the bucket entries with keys, doc counts, and sub-aggs + * @return the InternalAggregation + */ + InternalAggregation toBucketAggregation(T agg, List buckets); +} diff --git a/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/aggregation/bucket/TermsBucketTranslator.java b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/aggregation/bucket/TermsBucketTranslator.java new file mode 100644 index 0000000000000..f39e5a189a3de --- /dev/null +++ b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/aggregation/bucket/TermsBucketTranslator.java @@ -0,0 +1,50 @@ +/* + * 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.aggregation.bucket; + +import org.opensearch.dsl.aggregation.FieldGrouping; +import org.opensearch.dsl.aggregation.GroupingInfo; +import org.opensearch.dsl.result.BucketEntry; +import org.opensearch.search.aggregations.AggregationBuilder; +import org.opensearch.search.aggregations.InternalAggregation; +import org.opensearch.search.aggregations.bucket.terms.TermsAggregationBuilder; + +import java.util.Collection; +import java.util.List; + +/** + * Translates a {@link TermsAggregationBuilder} — single-field GROUP BY. + * {@code {"aggs": {"by_brand": {"terms": {"field": "brand"}}}}} becomes {@code GROUP BY brand}. + */ +public class TermsBucketTranslator implements BucketTranslator { + + /** Creates a terms bucket translator. */ + public TermsBucketTranslator() {} + + @Override + public Class getAggregationType() { + return TermsAggregationBuilder.class; + } + + @Override + public GroupingInfo getGrouping(TermsAggregationBuilder agg) { + return new FieldGrouping(List.of(agg.field())); + } + + @Override + public Collection getSubAggregations(TermsAggregationBuilder agg) { + return agg.getSubAggregations(); + } + + // TODO: implement response conversion + @Override + public InternalAggregation toBucketAggregation(TermsAggregationBuilder agg, List buckets) { + throw new UnsupportedOperationException("toBucketAggregation not yet implemented"); + } +} diff --git a/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/aggregation/bucket/package-info.java b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/aggregation/bucket/package-info.java new file mode 100644 index 0000000000000..d4b41a74dd36b --- /dev/null +++ b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/aggregation/bucket/package-info.java @@ -0,0 +1,10 @@ +/* + * 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. + */ + +/** Bucket aggregation translators — convert bucket agg builders to GROUP BY groupings. */ +package org.opensearch.dsl.aggregation.bucket; diff --git a/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/aggregation/metric/AbstractMetricTranslator.java b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/aggregation/metric/AbstractMetricTranslator.java new file mode 100644 index 0000000000000..092735439831d --- /dev/null +++ b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/aggregation/metric/AbstractMetricTranslator.java @@ -0,0 +1,74 @@ +/* + * 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.aggregation.metric; + +import org.apache.calcite.rel.RelCollations; +import org.apache.calcite.rel.core.AggregateCall; +import org.apache.calcite.rel.type.RelDataType; +import org.apache.calcite.rel.type.RelDataTypeField; +import org.apache.calcite.sql.SqlAggFunction; +import org.opensearch.dsl.converter.ConversionException; +import org.opensearch.search.aggregations.AggregationBuilder; +import org.opensearch.search.aggregations.InternalAggregation; + +import java.util.Collections; + +/** + * Base class for metric translators. Provides the common {@link #toAggregateCall} + * logic — subclasses supply the SQL aggregate function, field name, and optionally + * override the return type. + */ +public abstract class AbstractMetricTranslator implements MetricTranslator { + + /** Creates a metric translator. */ + protected AbstractMetricTranslator() {} + + /** Returns the SQL aggregate function (e.g., AVG, SUM, MIN, MAX). */ + protected abstract SqlAggFunction getAggFunction(); + + /** + * Returns the field name from the aggregation builder. + * + * @param agg the aggregation builder + * @return the field name + */ + protected abstract String getFieldName(T agg); + + @Override + public AggregateCall toAggregateCall(T agg, RelDataType rowType) throws ConversionException { + String fieldName = getFieldName(agg); + RelDataTypeField field = rowType.getField(fieldName, false, false); + if (field == null) { + throw new ConversionException("Aggregation field '" + fieldName + "' not found in schema"); + } + + return AggregateCall.create( + getAggFunction(), + false, + false, + false, + Collections.singletonList(field.getIndex()), + -1, + RelCollations.EMPTY, + field.getType(), + agg.getName() + ); + } + + @Override + public String getAggregateFieldName(T agg) { + return agg.getName(); + } + + // TODO: implement response conversion per metric type (InternalAvg, InternalSum, etc.) + @Override + public InternalAggregation toInternalAggregation(String name, Object value) { + throw new UnsupportedOperationException("toInternalAggregation not yet implemented for " + getClass().getSimpleName()); + } +} diff --git a/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/aggregation/metric/AvgMetricTranslator.java b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/aggregation/metric/AvgMetricTranslator.java new file mode 100644 index 0000000000000..caa9eebe7daf4 --- /dev/null +++ b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/aggregation/metric/AvgMetricTranslator.java @@ -0,0 +1,35 @@ +/* + * 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.aggregation.metric; + +import org.apache.calcite.sql.SqlAggFunction; +import org.apache.calcite.sql.fun.SqlStdOperatorTable; +import org.opensearch.search.aggregations.metrics.AvgAggregationBuilder; + +/** Translates AVG metric aggregation to Calcite. */ +public class AvgMetricTranslator extends AbstractMetricTranslator { + + /** Creates an AVG metric translator. */ + public AvgMetricTranslator() {} + + @Override + public Class getAggregationType() { + return AvgAggregationBuilder.class; + } + + @Override + protected SqlAggFunction getAggFunction() { + return SqlStdOperatorTable.AVG; + } + + @Override + protected String getFieldName(AvgAggregationBuilder agg) { + return agg.field(); + } +} diff --git a/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/aggregation/metric/MaxMetricTranslator.java b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/aggregation/metric/MaxMetricTranslator.java new file mode 100644 index 0000000000000..cda4b97690f13 --- /dev/null +++ b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/aggregation/metric/MaxMetricTranslator.java @@ -0,0 +1,35 @@ +/* + * 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.aggregation.metric; + +import org.apache.calcite.sql.SqlAggFunction; +import org.apache.calcite.sql.fun.SqlStdOperatorTable; +import org.opensearch.search.aggregations.metrics.MaxAggregationBuilder; + +/** Translates MAX metric aggregation to Calcite. */ +public class MaxMetricTranslator extends AbstractMetricTranslator { + + /** Creates a MAX metric translator. */ + public MaxMetricTranslator() {} + + @Override + public Class getAggregationType() { + return MaxAggregationBuilder.class; + } + + @Override + protected SqlAggFunction getAggFunction() { + return SqlStdOperatorTable.MAX; + } + + @Override + protected String getFieldName(MaxAggregationBuilder agg) { + return agg.field(); + } +} diff --git a/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/aggregation/metric/MetricTranslator.java b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/aggregation/metric/MetricTranslator.java new file mode 100644 index 0000000000000..7e4f8c7e1ec3d --- /dev/null +++ b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/aggregation/metric/MetricTranslator.java @@ -0,0 +1,50 @@ +/* + * 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.aggregation.metric; + +import org.apache.calcite.rel.core.AggregateCall; +import org.apache.calcite.rel.type.RelDataType; +import org.opensearch.dsl.aggregation.AggregationType; +import org.opensearch.dsl.converter.ConversionException; +import org.opensearch.search.aggregations.AggregationBuilder; +import org.opensearch.search.aggregations.InternalAggregation; + +/** + * Translates a metric aggregation (AVG, SUM, MIN, MAX, etc.) to a Calcite AggregateCall, + * and converts raw result values back to OpenSearch InternalAggregation for response building. + */ +public interface MetricTranslator extends AggregationType { + + /** + * Converts the metric aggregation to a Calcite AggregateCall. + * + * @param agg the metric aggregation builder + * @param rowType the index row type for field lookup + * @return the Calcite AggregateCall + * @throws ConversionException if conversion fails + */ + AggregateCall toAggregateCall(T agg, RelDataType rowType) throws ConversionException; + + /** + * Returns the output field name for this aggregation. + * + * @param agg the metric aggregation builder + * @return the aggregate field name + */ + String getAggregateFieldName(T agg); + + /** + * Converts a raw result value from execution into an OpenSearch InternalAggregation. + * + * @param name the aggregation name + * @param value the raw value (may be null) + * @return the corresponding InternalAggregation + */ + InternalAggregation toInternalAggregation(String name, Object value); +} diff --git a/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/aggregation/metric/MinMetricTranslator.java b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/aggregation/metric/MinMetricTranslator.java new file mode 100644 index 0000000000000..080b139591c97 --- /dev/null +++ b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/aggregation/metric/MinMetricTranslator.java @@ -0,0 +1,35 @@ +/* + * 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.aggregation.metric; + +import org.apache.calcite.sql.SqlAggFunction; +import org.apache.calcite.sql.fun.SqlStdOperatorTable; +import org.opensearch.search.aggregations.metrics.MinAggregationBuilder; + +/** Translates MIN metric aggregation to Calcite. */ +public class MinMetricTranslator extends AbstractMetricTranslator { + + /** Creates a MIN metric translator. */ + public MinMetricTranslator() {} + + @Override + public Class getAggregationType() { + return MinAggregationBuilder.class; + } + + @Override + protected SqlAggFunction getAggFunction() { + return SqlStdOperatorTable.MIN; + } + + @Override + protected String getFieldName(MinAggregationBuilder agg) { + return agg.field(); + } +} diff --git a/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/aggregation/metric/SumMetricTranslator.java b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/aggregation/metric/SumMetricTranslator.java new file mode 100644 index 0000000000000..6543a00d1aca0 --- /dev/null +++ b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/aggregation/metric/SumMetricTranslator.java @@ -0,0 +1,35 @@ +/* + * 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.aggregation.metric; + +import org.apache.calcite.sql.SqlAggFunction; +import org.apache.calcite.sql.fun.SqlStdOperatorTable; +import org.opensearch.search.aggregations.metrics.SumAggregationBuilder; + +/** Translates SUM metric aggregation to Calcite. */ +public class SumMetricTranslator extends AbstractMetricTranslator { + + /** Creates a SUM metric translator. */ + public SumMetricTranslator() {} + + @Override + public Class getAggregationType() { + return SumAggregationBuilder.class; + } + + @Override + protected SqlAggFunction getAggFunction() { + return SqlStdOperatorTable.SUM; + } + + @Override + protected String getFieldName(SumAggregationBuilder agg) { + return agg.field(); + } +} diff --git a/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/aggregation/metric/package-info.java b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/aggregation/metric/package-info.java new file mode 100644 index 0000000000000..952b48f992d92 --- /dev/null +++ b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/aggregation/metric/package-info.java @@ -0,0 +1,10 @@ +/* + * 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. + */ + +/** Metric aggregation translators — convert metric agg builders to Calcite AggregateCall. */ +package org.opensearch.dsl.aggregation.metric; diff --git a/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/aggregation/package-info.java b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/aggregation/package-info.java new file mode 100644 index 0000000000000..248d7a515357f --- /dev/null +++ b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/aggregation/package-info.java @@ -0,0 +1,10 @@ +/* + * 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. + */ + +/** Aggregation translation — converts DSL aggregations to Calcite LogicalAggregate. */ +package org.opensearch.dsl.aggregation; diff --git a/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/converter/AbstractDslConverter.java b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/converter/AbstractDslConverter.java new file mode 100644 index 0000000000000..317e20b7354ed --- /dev/null +++ b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/converter/AbstractDslConverter.java @@ -0,0 +1,54 @@ +/* + * 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.converter; + +import org.apache.calcite.rel.RelNode; + +/** + * Base class for DSL-to-RelNode converters. + * Subclasses implement {@link #isApplicable} to decide whether to run, + * and {@link #doConvert} for the actual conversion. + */ +public abstract class AbstractDslConverter { + + /** Creates a converter. */ + protected AbstractDslConverter() {} + + /** + * Converts the input if applicable, otherwise returns it unchanged. + * + * @param input the current plan + * @param ctx the conversion context + * @return the converted or unchanged plan + * @throws ConversionException if conversion fails + */ + public RelNode convert(RelNode input, ConversionContext ctx) throws ConversionException { + if (!isApplicable(ctx)) { + return input; + } + return doConvert(input, ctx); + } + + /** + * Returns true if this converter should run for the given context. + * + * @param ctx the conversion context + */ + protected abstract boolean isApplicable(ConversionContext ctx); + + /** + * Performs the actual conversion. + * + * @param input the current plan + * @param ctx the conversion context + * @return the converted plan + * @throws ConversionException if conversion fails + */ + protected abstract RelNode doConvert(RelNode input, ConversionContext ctx) throws ConversionException; +} diff --git a/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/converter/AggregateConverter.java b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/converter/AggregateConverter.java new file mode 100644 index 0000000000000..587e0e3a89b47 --- /dev/null +++ b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/converter/AggregateConverter.java @@ -0,0 +1,39 @@ +/* + * 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.converter; + +import org.apache.calcite.rel.RelNode; +import org.apache.calcite.rel.logical.LogicalAggregate; +import org.opensearch.dsl.aggregation.AggregationMetadata; + +/** + * Creates a {@link LogicalAggregate} from pre-computed {@link AggregationMetadata}. + * The metadata is produced by the tree walker and set on the context before this runs. + */ +public class AggregateConverter { + + /** Creates an aggregate converter. */ + public AggregateConverter() {} + + /** + * Builds a LogicalAggregate from the given metadata. + * + * @param input the input plan (scan + filter) + * @param metadata pre-computed aggregation metadata for one granularity + * @return the LogicalAggregate node + */ + public RelNode convert(RelNode input, AggregationMetadata metadata) { + return LogicalAggregate.create( + input, + metadata.getGroupByBitSet(), + null, + metadata.getAggregateCalls() + ); + } +} diff --git a/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/converter/ConversionContext.java b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/converter/ConversionContext.java new file mode 100644 index 0000000000000..4a9f924a1b74d --- /dev/null +++ b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/converter/ConversionContext.java @@ -0,0 +1,66 @@ +/* + * 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.converter; + +import org.apache.calcite.plan.RelOptCluster; +import org.apache.calcite.plan.RelOptTable; +import org.apache.calcite.rel.type.RelDataType; +import org.apache.calcite.rex.RexBuilder; +import org.opensearch.search.builder.SearchSourceBuilder; + +import java.util.Objects; + +/** + * Carries the shared state needed by converters and query translators + * during DSL-to-RelNode conversion. + */ +public class ConversionContext { + + private final SearchSourceBuilder searchSource; + private final RelOptCluster cluster; + private final RelOptTable table; + + /** + * Creates a conversion context. + * + * @param searchSource the original DSL query + * @param cluster the Calcite cluster for building expressions and RelNodes + * @param table the resolved Calcite table for the target index + */ + public ConversionContext(SearchSourceBuilder searchSource, RelOptCluster cluster, RelOptTable table) { + this.searchSource = Objects.requireNonNull(searchSource, "searchSource must not be null"); + this.cluster = Objects.requireNonNull(cluster, "cluster must not be null"); + this.table = Objects.requireNonNull(table, "table must not be null"); + } + + /** Returns the original DSL query. */ + public SearchSourceBuilder getSearchSource() { + return searchSource; + } + + /** Returns the Calcite cluster. */ + public RelOptCluster getCluster() { + return cluster; + } + + /** Returns the resolved Calcite table. */ + public RelOptTable getTable() { + return table; + } + + /** Returns the index row type (field names and types). */ + public RelDataType getRowType() { + return table.getRowType(); + } + + /** Returns the RexBuilder for creating expressions. */ + public RexBuilder getRexBuilder() { + return cluster.getRexBuilder(); + } +} diff --git a/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/converter/ConversionException.java b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/converter/ConversionException.java new file mode 100644 index 0000000000000..42f7b9e72d453 --- /dev/null +++ b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/converter/ConversionException.java @@ -0,0 +1,34 @@ +/* + * 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.converter; + +/** + * Thrown when DSL-to-Calcite conversion fails. + */ +public class ConversionException extends Exception { + + /** + * Creates a conversion exception. + * + * @param message description of what failed + */ + public ConversionException(String message) { + super(message); + } + + /** + * Creates a conversion exception with a cause. + * + * @param message description of what failed + * @param cause the underlying exception + */ + public ConversionException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/converter/FilterConverter.java b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/converter/FilterConverter.java new file mode 100644 index 0000000000000..22af14aee3f2e --- /dev/null +++ b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/converter/FilterConverter.java @@ -0,0 +1,47 @@ +/* + * 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.converter; + +import org.apache.calcite.rel.RelNode; +import org.apache.calcite.rel.logical.LogicalFilter; +import org.apache.calcite.rex.RexNode; +import org.opensearch.dsl.query.QueryRegistry; +import org.opensearch.index.query.MatchAllQueryBuilder; + +import java.util.Objects; + +/** + * Converts the DSL {@code query} clause to a {@link LogicalFilter}. + * Skips match_all since a table scan already returns all rows. + */ +public class FilterConverter extends AbstractDslConverter { + + private final QueryRegistry queryRegistry; + + /** + * Creates a filter converter. + * + * @param queryRegistry registry of query translators + */ + public FilterConverter(QueryRegistry queryRegistry) { + this.queryRegistry = Objects.requireNonNull(queryRegistry, "queryRegistry must not be null"); + } + + @Override + protected boolean isApplicable(ConversionContext ctx) { + return ctx.getSearchSource().query() != null + && !(ctx.getSearchSource().query() instanceof MatchAllQueryBuilder); + } + + @Override + protected RelNode doConvert(RelNode input, ConversionContext ctx) throws ConversionException { + RexNode condition = queryRegistry.convert(ctx.getSearchSource().query(), ctx); + return LogicalFilter.create(input, condition); + } +} diff --git a/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/converter/ProjectConverter.java b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/converter/ProjectConverter.java new file mode 100644 index 0000000000000..d59b9f30f7f47 --- /dev/null +++ b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/converter/ProjectConverter.java @@ -0,0 +1,126 @@ +/* + * 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.converter; + +import org.apache.calcite.rel.RelNode; +import org.apache.calcite.rel.logical.LogicalProject; +import org.apache.calcite.rel.type.RelDataType; +import org.apache.calcite.rel.type.RelDataTypeField; +import org.apache.calcite.rex.RexBuilder; +import org.apache.calcite.rex.RexNode; +import org.opensearch.search.fetch.subphase.FetchSourceContext; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.regex.Pattern; + +/** + * Converts {@code _source} field selection to a {@link LogicalProject}. + * Handles exact field names, wildcard patterns, and {@code _source: false}. + */ +public class ProjectConverter extends AbstractDslConverter { + + /** Creates a project converter. */ + public ProjectConverter() {} + + @Override + protected boolean isApplicable(ConversionContext ctx) { + return ctx.getSearchSource().fetchSource() != null; + } + + @Override + protected RelNode doConvert(RelNode input, ConversionContext ctx) throws ConversionException { + FetchSourceContext fetchSource = ctx.getSearchSource().fetchSource(); + + if (!fetchSource.fetchSource()) { + return LogicalProject.create(input, List.of(), List.of(), List.of()); + } + + String[] includes = fetchSource.includes(); + String[] excludes = fetchSource.excludes(); + boolean hasIncludes = includes != null && includes.length > 0; + boolean hasExcludes = excludes != null && excludes.length > 0; + + if (!hasIncludes && !hasExcludes) { + return input; + } + + return createProjection(input, includes, excludes, ctx.getRexBuilder()); + } + + private RelNode createProjection(RelNode input, String[] includes, String[] excludes, RexBuilder rexBuilder) + throws ConversionException { + RelDataType rowType = input.getRowType(); + List projects = new ArrayList<>(); + List fieldNames = new ArrayList<>(); + + if (includes != null && includes.length > 0) { + // Include mode: only listed fields + for (String pattern : includes) { + if (pattern.contains("*")) { + resolveWildcard(pattern, rowType, rexBuilder, projects, fieldNames); + } else { + resolveField(pattern, rowType, rexBuilder, projects, fieldNames); + } + } + } else { + // Exclude-only mode: all fields except excluded + Set excludeSet = buildExcludeSet(excludes, rowType); + for (RelDataTypeField field : rowType.getFieldList()) { + if (!excludeSet.contains(field.getName())) { + projects.add(rexBuilder.makeInputRef(field.getType(), field.getIndex())); + fieldNames.add(field.getName()); + } + } + } + + return LogicalProject.create(input, List.of(), projects, fieldNames); + } + + private Set buildExcludeSet(String[] excludes, RelDataType rowType) { + Set excludeSet = new HashSet<>(); + for (String pattern : excludes) { + if (pattern.contains("*")) { + Pattern compiled = Pattern.compile(pattern.replace(".", "\\.").replace("*", ".*")); + for (RelDataTypeField field : rowType.getFieldList()) { + if (compiled.matcher(field.getName()).matches()) { + excludeSet.add(field.getName()); + } + } + } else { + excludeSet.add(pattern); + } + } + return excludeSet; + } + + private void resolveWildcard(String pattern, RelDataType rowType, RexBuilder rexBuilder, + List projects, List fieldNames) { + Pattern compiled = Pattern.compile(pattern.replace(".", "\\.").replace("*", ".*")); + for (RelDataTypeField field : rowType.getFieldList()) { + if (compiled.matcher(field.getName()).matches()) { + projects.add(rexBuilder.makeInputRef(field.getType(), field.getIndex())); + fieldNames.add(field.getName()); + } + } + // No error if nothing matches — consistent with OpenSearch core, which returns empty _source + } + + private void resolveField(String fieldName, RelDataType rowType, RexBuilder rexBuilder, + List projects, List fieldNames) throws ConversionException { + RelDataTypeField field = rowType.getField(fieldName, false, false); + if (field == null) { + throw new ConversionException("Field '" + fieldName + "' not found in schema"); + } + projects.add(rexBuilder.makeInputRef(field.getType(), field.getIndex())); + fieldNames.add(field.getName()); + } +} diff --git a/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/converter/SearchSourceConverter.java b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/converter/SearchSourceConverter.java new file mode 100644 index 0000000000000..436e0bbce94cd --- /dev/null +++ b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/converter/SearchSourceConverter.java @@ -0,0 +1,133 @@ +/* + * 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.converter; + +import org.apache.calcite.config.CalciteConnectionConfigImpl; +import org.apache.calcite.jdbc.CalciteSchema; +import org.apache.calcite.plan.RelOptCluster; +import org.apache.calcite.plan.RelOptTable; +import org.apache.calcite.plan.hep.HepPlanner; +import org.apache.calcite.plan.hep.HepProgram; +import org.apache.calcite.prepare.CalciteCatalogReader; +import org.apache.calcite.rel.RelNode; +import org.apache.calcite.rel.logical.LogicalTableScan; +import org.apache.calcite.rel.type.RelDataTypeFactory; +import org.apache.calcite.rel.type.RelDataTypeSystem; +import org.apache.calcite.rex.RexBuilder; +import org.apache.calcite.schema.SchemaPlus; +import org.apache.calcite.sql.type.SqlTypeFactoryImpl; +import org.opensearch.dsl.aggregation.AggregationMetadata; +import org.opensearch.dsl.aggregation.AggregationRegistryFactory; +import org.opensearch.dsl.aggregation.AggregationTreeWalker; +import org.opensearch.dsl.executor.QueryPlans; +import org.opensearch.dsl.query.QueryRegistryFactory; +import org.opensearch.search.SearchService; +import org.opensearch.search.builder.SearchSourceBuilder; + +import java.util.Collections; +import java.util.List; +import java.util.Properties; + +/** + * Converts {@link SearchSourceBuilder} DSL into Calcite {@link QueryPlans}. + * + *

Builds its own Calcite planning infrastructure from the {@link SchemaPlus} provided + * by the analytics engine. + */ +public class SearchSourceConverter { + + private final RelOptCluster cluster; + private final CalciteCatalogReader catalogReader; + private final FilterConverter filterConverter; + private final ProjectConverter projectConverter; + private final SortConverter sortConverter; + private final AggregateConverter aggregateConverter; + private final AggregationTreeWalker treeWalker; + + /** + * Initializes planning infrastructure from the given schema. + * + * @param schema Calcite schema with index tables from the analytics engine + */ + public SearchSourceConverter(SchemaPlus schema) { + RelDataTypeFactory typeFactory = new SqlTypeFactoryImpl(RelDataTypeSystem.DEFAULT); + HepPlanner planner = new HepPlanner(HepProgram.builder().build()); + this.cluster = RelOptCluster.create(planner, new RexBuilder(typeFactory)); + + CalciteSchema rootSchema = CalciteSchema.from(schema); + this.catalogReader = new CalciteCatalogReader( + rootSchema, + Collections.singletonList(""), + typeFactory, + new CalciteConnectionConfigImpl(new Properties()) + ); + + this.filterConverter = new FilterConverter(QueryRegistryFactory.create()); + this.projectConverter = new ProjectConverter(); + this.sortConverter = new SortConverter(); + this.aggregateConverter = new AggregateConverter(); + + var aggRegistry = AggregationRegistryFactory.create(); + this.treeWalker = new AggregationTreeWalker(aggRegistry); + } + + /** + * Converts DSL for the given index into query plans. + * + * @param searchSource the DSL query + * @param indexName target index + * @return one or more query plans + * @throws ConversionException if DSL conversion fails + */ + public QueryPlans convert(SearchSourceBuilder searchSource, String indexName) throws ConversionException { + RelOptTable table = catalogReader.getTable(List.of(indexName)); + if (table == null) { + throw new IllegalArgumentException("Index not found in schema: " + indexName); + } + + ConversionContext ctx = new ConversionContext(searchSource, cluster, table); + + // Shared base: Scan → Filter + RelNode base = LogicalTableScan.create(cluster, table, List.of()); + base = filterConverter.convert(base, ctx); + + int size = searchSource.size() != -1 ? searchSource.size() : SearchService.DEFAULT_SIZE; + boolean hasAggs = hasAggregations(searchSource); + + QueryPlans.Builder builder = new QueryPlans.Builder(); + + // Hits path: Scan → Filter → Project → Sort + if (size > 0 || !hasAggs) { + RelNode hits = projectConverter.convert(base, ctx); + hits = sortConverter.convert(hits, ctx); + builder.add(new QueryPlans.QueryPlan(QueryPlans.Type.HITS, hits)); + } + + // Aggregation path: Scan → Filter → Aggregate (one per granularity level) + if (hasAggs) { + List metadataList = treeWalker.walk( + searchSource.aggregations().getAggregatorFactories(), + table.getRowType(), + cluster.getTypeFactory() + ); + for (AggregationMetadata metadata : metadataList) { + RelNode aggs = aggregateConverter.convert(base, metadata); + builder.add(new QueryPlans.QueryPlan(QueryPlans.Type.AGGREGATION, aggs)); + } + } + + return builder.build(); + } + + private static boolean hasAggregations(SearchSourceBuilder searchSource) { + return searchSource.aggregations() != null + && searchSource.aggregations().getAggregatorFactories() != null + && !searchSource.aggregations().getAggregatorFactories().isEmpty(); + } +} diff --git a/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/converter/SortConverter.java b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/converter/SortConverter.java new file mode 100644 index 0000000000000..4cee02da1b1a8 --- /dev/null +++ b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/converter/SortConverter.java @@ -0,0 +1,118 @@ +/* + * 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.converter; + +import org.apache.calcite.rel.RelCollation; +import org.apache.calcite.rel.RelCollations; +import org.apache.calcite.rel.RelFieldCollation; +import org.apache.calcite.rel.RelNode; +import org.apache.calcite.rel.logical.LogicalSort; +import org.apache.calcite.rel.type.RelDataType; +import org.apache.calcite.rel.type.RelDataTypeField; +import org.apache.calcite.rex.RexNode; +import org.apache.calcite.sql.type.SqlTypeName; +import org.opensearch.search.SearchService; +import org.opensearch.search.builder.SearchSourceBuilder; +import org.opensearch.search.sort.FieldSortBuilder; +import org.opensearch.search.sort.SortBuilder; +import org.opensearch.search.sort.SortOrder; + +import java.util.ArrayList; +import java.util.List; + +/** + * Converts DSL {@code sort}, {@code from}, and {@code size} into a {@link LogicalSort} + * with collation (ordering) and offset/fetch (pagination). + */ +public class SortConverter extends AbstractDslConverter { + + /** Creates a sort converter. */ + public SortConverter() {} + + // Core defaults to _score DESC when no sort is specified. The analytics engine + // has no relevance scoring, so unsorted queries return rows in unspecified order. + // TODO: handle ScoreSortBuilder (_score sort) + @Override + protected boolean isApplicable(ConversionContext ctx) { + return hasSort(ctx) || hasNonDefaultPagination(ctx); + } + + @Override + protected RelNode doConvert(RelNode input, ConversionContext ctx) throws ConversionException { + RelCollation collation = buildCollation(input, ctx); + RexNode offset = buildOffset(ctx); + RexNode fetch = buildFetch(ctx); + + return LogicalSort.create(input, collation, offset, fetch); + } + + private RelCollation buildCollation(RelNode input, ConversionContext ctx) throws ConversionException { + if (!hasSort(ctx)) { + return RelCollations.EMPTY; + } + + RelDataType rowType = input.getRowType(); + List fieldCollations = new ArrayList<>(); + + for (SortBuilder sortBuilder : ctx.getSearchSource().sorts()) { + if (sortBuilder instanceof FieldSortBuilder fieldSort) { + String fieldName = fieldSort.getFieldName(); + RelDataTypeField field = rowType.getField(fieldName, false, false); + if (field == null) { + throw new ConversionException("Sort field '" + fieldName + "' not found in schema"); + } + + RelFieldCollation.Direction direction = (fieldSort.order() == SortOrder.ASC) + ? RelFieldCollation.Direction.ASCENDING + : RelFieldCollation.Direction.DESCENDING; + + RelFieldCollation.NullDirection nullDirection = (fieldSort.order() == SortOrder.ASC) + ? RelFieldCollation.NullDirection.LAST + : RelFieldCollation.NullDirection.FIRST; + + fieldCollations.add(new RelFieldCollation(field.getIndex(), direction, nullDirection)); + } else { + throw new ConversionException("Sort type not supported: " + sortBuilder.getClass().getSimpleName()); + } + } + + return RelCollations.of(fieldCollations); + } + + private RexNode buildOffset(ConversionContext ctx) { + SearchSourceBuilder ss = ctx.getSearchSource(); + int from = ss.from() != -1 ? ss.from() : SearchService.DEFAULT_FROM; + if (from <= 0) { + return null; + } + return ctx.getRexBuilder().makeLiteral(from, + ctx.getCluster().getTypeFactory().createSqlType(SqlTypeName.INTEGER), false); + } + + private RexNode buildFetch(ConversionContext ctx) { + if (!hasNonDefaultPagination(ctx)) { + return null; + } + SearchSourceBuilder ss = ctx.getSearchSource(); + int size = ss.size() != -1 ? ss.size() : SearchService.DEFAULT_SIZE; + return ctx.getRexBuilder().makeLiteral(size, + ctx.getCluster().getTypeFactory().createSqlType(SqlTypeName.INTEGER), false); + } + + private static boolean hasSort(ConversionContext ctx) { + return ctx.getSearchSource().sorts() != null && !ctx.getSearchSource().sorts().isEmpty(); + } + + private static boolean hasNonDefaultPagination(ConversionContext ctx) { + SearchSourceBuilder ss = ctx.getSearchSource(); + int from = ss.from() != -1 ? ss.from() : SearchService.DEFAULT_FROM; + int size = ss.size() != -1 ? ss.size() : SearchService.DEFAULT_SIZE; + return !(from == SearchService.DEFAULT_FROM && size == SearchService.DEFAULT_SIZE); + } +} diff --git a/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/converter/package-info.java b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/converter/package-info.java new file mode 100644 index 0000000000000..143ddccf7031b --- /dev/null +++ b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/converter/package-info.java @@ -0,0 +1,10 @@ +/* + * 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. + */ + +/** DSL-to-Calcite conversion logic. */ +package org.opensearch.dsl.converter; diff --git a/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/executor/DslQueryPlanExecutor.java b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/executor/DslQueryPlanExecutor.java new file mode 100644 index 0000000000000..0a2ab3e151eb2 --- /dev/null +++ b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/executor/DslQueryPlanExecutor.java @@ -0,0 +1,75 @@ +/* + * 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.executor; + +import org.apache.calcite.rel.RelNode; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.opensearch.analytics.exec.QueryPlanExecutor; +import org.opensearch.dsl.result.ExecutionResult; + +import java.util.ArrayList; +import java.util.List; + +/** + * Iterates over {@link QueryPlans}, delegates each RelNode to the analytics engine's + * {@link QueryPlanExecutor}, and collects results. + */ +public class DslQueryPlanExecutor { + + private static final Logger logger = LogManager.getLogger(DslQueryPlanExecutor.class); + + private final QueryPlanExecutor> executor; + + /** + * Creates an executor backed by the given analytics engine plan executor. + * + * @param executor analytics engine executor that runs individual RelNode plans + */ + public DslQueryPlanExecutor(QueryPlanExecutor> executor) { + this.executor = executor; + } + + // TODO: add per-plan error handling so a failure in one plan + // doesn't prevent returning partial results from other plans (e.g. HITS) + /** + * Executes all plans and returns results in plan order. + * + * @param plans the query plans to execute + * @return execution results, one per plan + */ + public List execute(QueryPlans plans) { + List queryPlans = plans.getAll(); + List results = new ArrayList<>(queryPlans.size()); + + for (QueryPlans.QueryPlan plan : queryPlans) { + RelNode relNode = plan.relNode(); + logPlan(relNode); + // TODO: context param is null, may carry execution hints + Iterable rows = executor.execute(relNode, null); + results.add(new ExecutionResult(plan, rows)); + } + + return results; + } + + // TODO: move plan logging behind a debug flag + // invalidateMetadataQuery() and THREAD_PROVIDERS are only needed for explain() output + private void logPlan(RelNode relNode) { + if (logger.isInfoEnabled()) { + org.apache.calcite.rel.metadata.RelMetadataQueryBase.THREAD_PROVIDERS.set( + org.apache.calcite.rel.metadata.JaninoRelMetadataProvider.of( + java.util.Objects.requireNonNull(relNode.getCluster().getMetadataProvider()) + ) + ); + relNode.getCluster().invalidateMetadataQuery(); + logger.info("Executing RelNode:\n{}", relNode.explain()); + } + } +} diff --git a/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/executor/QueryPlans.java b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/executor/QueryPlans.java new file mode 100644 index 0000000000000..e6d16165f646c --- /dev/null +++ b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/executor/QueryPlans.java @@ -0,0 +1,115 @@ +/* + * 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.executor; + +import org.apache.calcite.rel.RelNode; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** + * One or more query plans produced by DSL to RelNode conversion. + */ +public final class QueryPlans { + + /** Identifies what part of the SearchResponse a plan populates. */ + public enum Type { + /** Document hits. */ + HITS, + /** Aggregation results. */ + AGGREGATION + } + + /** + * A single plan pairing a {@link Type} with a Calcite {@link RelNode}. + * + * @param type what part of the response this plan produces + * @param relNode the Calcite logical plan to execute + */ + public record QueryPlan(Type type, RelNode relNode) { + /** + * Creates a query plan. + * + * @param type what part of the response this plan produces + * @param relNode the Calcite logical plan to execute + */ + public QueryPlan { + Objects.requireNonNull(type, "type must not be null"); + Objects.requireNonNull(relNode, "relNode must not be null"); + } + + /** Returns what part of the response this plan produces. */ + @Override + public Type type() { + return type; + } + + /** Returns the Calcite logical plan to execute. */ + @Override + public RelNode relNode() { + return relNode; + } + } + + private final List plans; + + private QueryPlans(List plans) { + this.plans = List.copyOf(plans); + } + + /** Returns all plans. */ + public List getAll() { + return plans; + } + + /** + * Returns all plans matching the given type. + * + * @param type the plan type to look up + */ + public List get(Type type) { + return plans.stream().filter(p -> p.type() == type).toList(); + } + + /** + * Returns true if a plan with the given type exists. + * + * @param type the plan type to check + */ + public boolean has(Type type) { + return plans.stream().anyMatch(p -> p.type() == type); + } + + /** Builder for constructing {@link QueryPlans}. */ + public static class Builder { + private final List plans = new ArrayList<>(); + + /** Creates a new empty builder. */ + public Builder() {} + + /** + * Adds a plan. + * + * @param plan the plan to add + */ + public Builder add(QueryPlan plan) { + plans.add(plan); + return this; + } + + /** Builds the plans. At least one must have been added. */ + public QueryPlans build() { + if (plans.isEmpty()) { + throw new IllegalStateException("QueryPlans must have at least one plan"); + } + return new QueryPlans(plans); + } + } +} diff --git a/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/executor/package-info.java b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/executor/package-info.java new file mode 100644 index 0000000000000..1f4e068a37040 --- /dev/null +++ b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/executor/package-info.java @@ -0,0 +1,10 @@ +/* + * 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. + */ + +/** Query plan execution and plan containers. */ +package org.opensearch.dsl.executor; diff --git a/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/package-info.java b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/package-info.java new file mode 100644 index 0000000000000..1253f51ca88fb --- /dev/null +++ b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/package-info.java @@ -0,0 +1,13 @@ +/* + * 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. + */ + +/** + * DSL query executor — converts OpenSearch DSL queries to Calcite logical plans + * and executes them via the analytics engine. + */ +package org.opensearch.dsl; diff --git a/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/query/MatchAllQueryTranslator.java b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/query/MatchAllQueryTranslator.java new file mode 100644 index 0000000000000..8322fcee3f582 --- /dev/null +++ b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/query/MatchAllQueryTranslator.java @@ -0,0 +1,36 @@ +/* + * 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.RexNode; +import org.opensearch.dsl.converter.ConversionContext; +import org.opensearch.dsl.converter.ConversionException; +import org.opensearch.index.query.MatchAllQueryBuilder; +import org.opensearch.index.query.QueryBuilder; + +/** + * Converts a {@link MatchAllQueryBuilder} to a boolean TRUE literal. + * A top-level match_all produces no filter (table scan returns all rows), + * but match_all can appear nested inside bool queries and needs a translator. + */ +public class MatchAllQueryTranslator implements QueryTranslator { + + /** Creates a new match-all query translator. */ + public MatchAllQueryTranslator() {} + + @Override + public Class getQueryType() { + return MatchAllQueryBuilder.class; + } + + @Override + public RexNode convert(QueryBuilder query, ConversionContext ctx) { + return ctx.getRexBuilder().makeLiteral(true); + } +} diff --git a/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/query/QueryRegistry.java b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/query/QueryRegistry.java new file mode 100644 index 0000000000000..70c7aa94dc7ac --- /dev/null +++ b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/query/QueryRegistry.java @@ -0,0 +1,61 @@ +/* + * 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.RexNode; +import org.apache.calcite.sql.type.SqlTypeName; +import org.opensearch.dsl.converter.ConversionContext; +import org.opensearch.dsl.converter.ConversionException; +import org.opensearch.index.query.QueryBuilder; + +import java.util.HashMap; +import java.util.Map; + +/** + * Registry of {@link QueryTranslator}s keyed by QueryBuilder class. + * O(1) lookup — known query types get standard Rex, unknown ones get {@link UnresolvedQueryCall}. + */ +public class QueryRegistry { + + private final Map, QueryTranslator> translators = new HashMap<>(); + + /** Creates an empty registry. */ + public QueryRegistry() {} + + /** + * Registers a translator. + * + * @param translator the translator to register + */ + public void register(QueryTranslator translator) { + translators.put(translator.getQueryType(), translator); + } + + /** + * Converts a query using the registered translator for its type. + * If no translator is registered, wraps the query in an {@link UnresolvedQueryCall} + * for the analytics engine's optimizer to resolve or reject. + * + * @param query the query builder to convert + * @param ctx the conversion context + * @return the resulting RexNode — standard Rex or UnresolvedQueryCall + * @throws ConversionException if a registered translator fails + */ + public RexNode convert(QueryBuilder query, ConversionContext ctx) throws ConversionException { + QueryTranslator translator = translators.get(query.getClass()); + if (translator != null) { + return translator.convert(query, ctx); + } + // No translator — wrap as unresolved for downstream to handle + return new UnresolvedQueryCall( + ctx.getCluster().getTypeFactory().createSqlType(SqlTypeName.BOOLEAN), + query + ); + } +} 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 new file mode 100644 index 0000000000000..5313c1d40253b --- /dev/null +++ b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/query/QueryRegistryFactory.java @@ -0,0 +1,26 @@ +/* + * 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; + +/** + * Creates a {@link QueryRegistry} populated with all supported query translators. + */ +public class QueryRegistryFactory { + + private QueryRegistryFactory() {} + + /** Creates a registry with all supported query translators. */ + public static QueryRegistry create() { + QueryRegistry registry = new QueryRegistry(); + registry.register(new TermQueryTranslator()); + registry.register(new MatchAllQueryTranslator()); + // TODO: add other query translators + return registry; + } +} diff --git a/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/query/QueryTranslator.java b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/query/QueryTranslator.java new file mode 100644 index 0000000000000..44685cdfacbd4 --- /dev/null +++ b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/query/QueryTranslator.java @@ -0,0 +1,34 @@ +/* + * 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.RexNode; +import org.opensearch.dsl.converter.ConversionContext; +import org.opensearch.dsl.converter.ConversionException; +import org.opensearch.index.query.QueryBuilder; + +/** + * Translates a single OpenSearch query type to a Calcite RexNode. + * One implementation per query type (term, match_all, range, bool, etc.). + */ +public interface QueryTranslator { + + /** Returns the concrete QueryBuilder class this translator handles. */ + Class getQueryType(); + + /** + * Converts the query to a Calcite RexNode filter expression. + * + * @param query the query builder to convert + * @param ctx the conversion context + * @return the resulting RexNode + * @throws ConversionException if conversion fails + */ + RexNode convert(QueryBuilder query, ConversionContext ctx) throws ConversionException; +} diff --git a/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/query/TermQueryTranslator.java b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/query/TermQueryTranslator.java new file mode 100644 index 0000000000000..9f43be3cf63da --- /dev/null +++ b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/query/TermQueryTranslator.java @@ -0,0 +1,49 @@ +/* + * 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.converter.ConversionContext; +import org.opensearch.dsl.converter.ConversionException; +import org.opensearch.index.query.QueryBuilder; +import org.opensearch.index.query.TermQueryBuilder; + +/** + * Converts a {@link TermQueryBuilder} to a Calcite EQUALS RexNode. + * {@code {"term": {"status": "active"}}} becomes {@code status = 'active'}. + */ +public class TermQueryTranslator implements QueryTranslator { + + /** Creates a new term query translator. */ + public TermQueryTranslator() {} + + @Override + public Class getQueryType() { + return TermQueryBuilder.class; + } + + @Override + public RexNode convert(QueryBuilder query, ConversionContext ctx) throws ConversionException { + TermQueryBuilder termQuery = (TermQueryBuilder) query; + String fieldName = termQuery.fieldName(); + Object value = termQuery.value(); + + RelDataTypeField field = ctx.getRowType().getField(fieldName, false, false); + if (field == null) { + throw new ConversionException("Field '" + fieldName + "' not found in schema"); + } + + RexNode fieldRef = ctx.getRexBuilder().makeInputRef(field.getType(), field.getIndex()); + RexNode literal = ctx.getRexBuilder().makeLiteral(value, field.getType(), true); + + return ctx.getRexBuilder().makeCall(SqlStdOperatorTable.EQUALS, fieldRef, literal); + } +} diff --git a/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/query/UnresolvedQueryCall.java b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/query/UnresolvedQueryCall.java new file mode 100644 index 0000000000000..2c490d6578b15 --- /dev/null +++ b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/query/UnresolvedQueryCall.java @@ -0,0 +1,58 @@ +/* + * 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.RelDataType; +import org.apache.calcite.rex.RexCall; +import org.apache.calcite.sql.SqlFunction; +import org.apache.calcite.sql.SqlFunctionCategory; +import org.apache.calcite.sql.SqlKind; +import org.apache.calcite.sql.type.ReturnTypes; +import org.opensearch.index.query.QueryBuilder; + +import java.util.List; + +/** + * A RexCall that wraps a QueryBuilder that couldn't be converted to standard Calcite Rex expressions. + * + *

The analytics engine's optimizer can inspect these nodes via {@link #getQueryBuilder()} and + * decide how to handle them — push to Lucene, absorb into a physical scan, or reject. + * + *

Reusable for any query type: wildcard, match, query_string, fuzzy, etc. + */ +public class UnresolvedQueryCall extends RexCall { + + /** Marker operator identifying unresolved query nodes in the plan. */ + public static final SqlFunction UNRESOLVED_QUERY = new SqlFunction( + "UNRESOLVED_QUERY", + SqlKind.OTHER_FUNCTION, + ReturnTypes.BOOLEAN, + null, + null, + SqlFunctionCategory.USER_DEFINED_FUNCTION + ); + + private final QueryBuilder queryBuilder; + + /** + * Creates an unresolved query call. + * + * @param type the return type (boolean — it's a filter condition) + * @param queryBuilder the original OpenSearch query that couldn't be converted + */ + public UnresolvedQueryCall(RelDataType type, QueryBuilder queryBuilder) { + super(type, UNRESOLVED_QUERY, List.of()); + this.queryBuilder = queryBuilder; + } + + /** Returns the original OpenSearch query builder. */ + public QueryBuilder getQueryBuilder() { + return queryBuilder; + } +} diff --git a/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/query/package-info.java b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/query/package-info.java new file mode 100644 index 0000000000000..ba42c1c221214 --- /dev/null +++ b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/query/package-info.java @@ -0,0 +1,10 @@ +/* + * 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. + */ + +/** DSL query type translators — convert individual query builders to Calcite RexNode expressions. */ +package org.opensearch.dsl.query; diff --git a/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/result/BucketEntry.java b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/result/BucketEntry.java new file mode 100644 index 0000000000000..74adcf4685fd7 --- /dev/null +++ b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/result/BucketEntry.java @@ -0,0 +1,49 @@ +/* + * 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.result; + +import org.opensearch.search.aggregations.InternalAggregations; + +import java.util.List; + +/** + * A single bucket entry for response construction. + * + * @param keys the bucket key values (single for terms, multiple for multi_terms) + * @param docCount the document count for this bucket + * @param subAggs the sub-aggregation results for this bucket + */ +public record BucketEntry(List keys, long docCount, InternalAggregations subAggs) { + /** + * Creates a bucket entry. + * + * @param keys the bucket key values + * @param docCount the document count + * @param subAggs the sub-aggregation results + */ + public BucketEntry {} + + /** Returns the bucket key values. */ + @Override + public List keys() { + return keys; + } + + /** Returns the document count. */ + @Override + public long docCount() { + return docCount; + } + + /** Returns the sub-aggregation results. */ + @Override + public InternalAggregations subAggs() { + return subAggs; + } +} diff --git a/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/result/ExecutionResult.java b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/result/ExecutionResult.java new file mode 100644 index 0000000000000..a03308c24ffbb --- /dev/null +++ b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/result/ExecutionResult.java @@ -0,0 +1,55 @@ +/* + * 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.result; + +import org.opensearch.dsl.executor.QueryPlans; + +import java.util.List; +import java.util.Objects; + +/** + * Result of executing a single {@link QueryPlans.QueryPlan}: the original plan + * paired with the rows returned by the analytics engine. + */ +public final class ExecutionResult { + + private final QueryPlans.QueryPlan plan; + private final Iterable rows; + + /** + * Creates a result for the given plan and rows. + * + * @param plan the plan that produced this result + * @param rows result rows from the executor + */ + public ExecutionResult(QueryPlans.QueryPlan plan, Iterable rows) { + this.plan = Objects.requireNonNull(plan, "plan must not be null"); + this.rows = Objects.requireNonNull(rows, "rows must not be null"); + } + + /** Returns the plan that produced this result. */ + public QueryPlans.QueryPlan getPlan() { + return plan; + } + + /** Returns the plan type (HITS or AGGREGATION). */ + public QueryPlans.Type getType() { + return plan.type(); + } + + /** Returns the result rows from the executor. */ + public Iterable getRows() { + return rows; + } + + /** Column names derived from the plan's RelNode row type. */ + public List getFieldNames() { + return plan.relNode().getRowType().getFieldNames(); + } +} diff --git a/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/result/SearchResponseBuilder.java b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/result/SearchResponseBuilder.java new file mode 100644 index 0000000000000..0cdade670eb13 --- /dev/null +++ b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/result/SearchResponseBuilder.java @@ -0,0 +1,46 @@ +/* + * 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.result; + +import org.opensearch.action.search.SearchResponse; +import org.opensearch.action.search.SearchResponseSections; +import org.opensearch.action.search.ShardSearchFailure; +import org.opensearch.search.SearchHits; + +import java.util.List; + +/** + * Builds a {@link SearchResponse} from execution results. + */ +public class SearchResponseBuilder { + + private SearchResponseBuilder() {} + + /** + * Builds a SearchResponse from the given results and timing. + * + * @param results execution results from the plan executor + * @param tookInMillis total execution time in milliseconds + * @return a SearchResponse + */ + public static SearchResponse build(List results, long tookInMillis) { + // TODO: populate hits and aggregations from results + SearchHits hits = SearchHits.empty(true); + SearchResponseSections sections = new SearchResponseSections(hits, null, null, false, null, null, 0); + return new SearchResponse( + sections, + null, + 0, + 0, + 0, + tookInMillis, + ShardSearchFailure.EMPTY_ARRAY, + SearchResponse.Clusters.EMPTY); + } +} diff --git a/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/result/package-info.java b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/result/package-info.java new file mode 100644 index 0000000000000..c515295d29846 --- /dev/null +++ b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/result/package-info.java @@ -0,0 +1,10 @@ +/* + * 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. + */ + +/** Execution results and SearchResponse construction. */ +package org.opensearch.dsl.result; diff --git a/sandbox/plugins/dsl-query-executor/src/test/java/org/opensearch/dsl/DslQueryExecutorPluginTests.java b/sandbox/plugins/dsl-query-executor/src/test/java/org/opensearch/dsl/DslQueryExecutorPluginTests.java new file mode 100644 index 0000000000000..e5542771e93d1 --- /dev/null +++ b/sandbox/plugins/dsl-query-executor/src/test/java/org/opensearch/dsl/DslQueryExecutorPluginTests.java @@ -0,0 +1,60 @@ +/* + * 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.opensearch.action.support.ActionFilter; +import org.opensearch.dsl.action.DslExecuteAction; +import org.opensearch.dsl.action.SearchActionFilter; +import org.opensearch.dsl.action.TransportDslExecuteAction; +import org.opensearch.plugins.ActionPlugin; +import org.opensearch.test.OpenSearchTestCase; +import org.opensearch.transport.client.node.NodeClient; + +import java.util.List; + +import static org.mockito.Mockito.mock; + +public class DslQueryExecutorPluginTests extends OpenSearchTestCase { + + private DslQueryExecutorPlugin plugin; + + @Override + public void setUp() throws Exception { + super.setUp(); + plugin = new DslQueryExecutorPlugin(); + } + + public void testGetActionFiltersEmptyBeforeCreateComponents() { + List filters = plugin.getActionFilters(); + + assertTrue(filters.isEmpty()); + } + + public void testGetActionFiltersAfterCreateComponents() { + plugin.createComponents( + mock(NodeClient.class), + null, null, null, null, + null, null, null, + null, null, null + ); + + List filters = plugin.getActionFilters(); + assertEquals(1, filters.size()); + assertTrue(filters.get(0) instanceof SearchActionFilter); + } + + public void testRegistersTransportAction() { + var actions = plugin.getActions(); + + assertEquals(1, actions.size()); + ActionPlugin.ActionHandler handler = actions.get(0); + assertEquals(DslExecuteAction.INSTANCE, handler.getAction()); + assertEquals(TransportDslExecuteAction.class, handler.getTransportAction()); + } +} diff --git a/sandbox/plugins/dsl-query-executor/src/test/java/org/opensearch/dsl/TestUtils.java b/sandbox/plugins/dsl-query-executor/src/test/java/org/opensearch/dsl/TestUtils.java new file mode 100644 index 0000000000000..48430cd66f4d6 --- /dev/null +++ b/sandbox/plugins/dsl-query-executor/src/test/java/org/opensearch/dsl/TestUtils.java @@ -0,0 +1,93 @@ +/* + * 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.calcite.config.CalciteConnectionConfigImpl; +import org.apache.calcite.jdbc.CalciteSchema; +import org.apache.calcite.plan.RelOptCluster; +import org.apache.calcite.plan.RelOptTable; +import org.apache.calcite.plan.hep.HepPlanner; +import org.apache.calcite.plan.hep.HepProgram; +import org.apache.calcite.prepare.CalciteCatalogReader; +import org.apache.calcite.rel.logical.LogicalTableScan; +import org.apache.calcite.rel.type.RelDataType; +import org.apache.calcite.rel.type.RelDataTypeFactory; +import org.apache.calcite.rel.type.RelDataTypeSystem; +import org.apache.calcite.rex.RexBuilder; +import org.apache.calcite.schema.SchemaPlus; +import org.apache.calcite.schema.impl.AbstractTable; +import org.apache.calcite.sql.type.SqlTypeFactoryImpl; +import org.apache.calcite.sql.type.SqlTypeName; +import org.opensearch.dsl.converter.ConversionContext; +import org.opensearch.search.builder.SearchSourceBuilder; + +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.Properties; + +/** + * Shared test utilities for creating Calcite objects. + * Mockito can't mock Calcite classes due to classloader conflicts with OpenSearch's + * RandomizedRunner, so tests use real objects built here. + * + * Standard test schema: name (VARCHAR), price (INTEGER), brand (VARCHAR), rating (DOUBLE). + */ +public class TestUtils { + + private TestUtils() {} + + /** Creates a LogicalTableScan backed by the standard test schema. */ + public static LogicalTableScan createTestRelNode() { + Infra infra = buildInfra(); + return LogicalTableScan.create(infra.cluster, infra.table, List.of()); + } + + /** Creates a ConversionContext with the given search source and standard test schema. */ + public static ConversionContext createContext(SearchSourceBuilder searchSource) { + Infra infra = buildInfra(); + return new ConversionContext(searchSource, infra.cluster, infra.table); + } + + /** Creates a ConversionContext with an empty search source and standard test schema. */ + public static ConversionContext createContext() { + return createContext(new SearchSourceBuilder()); + } + + private static Infra buildInfra() { + RelDataTypeFactory typeFactory = new SqlTypeFactoryImpl(RelDataTypeSystem.DEFAULT); + HepPlanner planner = new HepPlanner(HepProgram.builder().build()); + RelOptCluster cluster = RelOptCluster.create(planner, new RexBuilder(typeFactory)); + + SchemaPlus schema = CalciteSchema.createRootSchema(true).plus(); + schema.add("test", new AbstractTable() { + @Override + public RelDataType getRowType(RelDataTypeFactory tf) { + // Nullable fields — matches OpenSearchSchemaBuilder behavior + return tf.builder() + .add("name", tf.createTypeWithNullability(tf.createSqlType(SqlTypeName.VARCHAR), true)) + .add("price", tf.createTypeWithNullability(tf.createSqlType(SqlTypeName.INTEGER), true)) + .add("brand", tf.createTypeWithNullability(tf.createSqlType(SqlTypeName.VARCHAR), true)) + .add("rating", tf.createTypeWithNullability(tf.createSqlType(SqlTypeName.DOUBLE), true)) + .build(); + } + }); + + CalciteCatalogReader reader = new CalciteCatalogReader( + CalciteSchema.from(schema), + Collections.singletonList(""), + typeFactory, + new CalciteConnectionConfigImpl(new Properties()) + ); + RelOptTable table = Objects.requireNonNull(reader.getTable(List.of("test"))); + return new Infra(cluster, table); + } + + private record Infra(RelOptCluster cluster, RelOptTable table) {} +} diff --git a/sandbox/plugins/dsl-query-executor/src/test/java/org/opensearch/dsl/action/SearchActionFilterTests.java b/sandbox/plugins/dsl-query-executor/src/test/java/org/opensearch/dsl/action/SearchActionFilterTests.java new file mode 100644 index 0000000000000..1e5e38531d3f0 --- /dev/null +++ b/sandbox/plugins/dsl-query-executor/src/test/java/org/opensearch/dsl/action/SearchActionFilterTests.java @@ -0,0 +1,61 @@ +/* + * 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.action; + +import org.opensearch.action.ActionRequest; +import org.opensearch.action.bulk.BulkAction; +import org.opensearch.action.bulk.BulkRequest; +import org.opensearch.action.search.SearchAction; +import org.opensearch.action.search.SearchRequest; +import org.opensearch.action.support.ActionFilterChain; +import org.opensearch.core.action.ActionListener; +import org.opensearch.core.action.ActionResponse; +import org.opensearch.tasks.Task; +import org.opensearch.test.OpenSearchTestCase; +import org.opensearch.transport.client.node.NodeClient; + +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +@SuppressWarnings("unchecked") +public class SearchActionFilterTests extends OpenSearchTestCase { + + private final NodeClient client = mock(NodeClient.class); + private final Task task = mock(Task.class); + private final ActionListener listener = mock(ActionListener.class); + private final ActionFilterChain chain = mock(ActionFilterChain.class); + private final SearchActionFilter filter = new SearchActionFilter(client); + + public void testOrderIsMinValue() { + assertEquals(Integer.MIN_VALUE, filter.order()); + } + + public void testPassesThroughNonSearchAction() { + BulkRequest request = new BulkRequest(); + + filter.apply(task, BulkAction.NAME, request, listener, chain); + + verify(chain).proceed(task, BulkAction.NAME, request, listener); + verify(client, never()).execute(any(), any(), any()); + } + + // TODO: add tests to verify reroute only happens when the target index has the setting enabled + + public void testReroutesSearchAction() { + SearchRequest request = new SearchRequest("test-index"); + + filter.apply(task, SearchAction.NAME, request, listener, chain); + + verify(client).execute(eq(DslExecuteAction.INSTANCE), eq(request), any()); + verify(chain, never()).proceed(any(), any(), any(), any()); + } +} diff --git a/sandbox/plugins/dsl-query-executor/src/test/java/org/opensearch/dsl/action/TransportDslExecuteActionTests.java b/sandbox/plugins/dsl-query-executor/src/test/java/org/opensearch/dsl/action/TransportDslExecuteActionTests.java new file mode 100644 index 0000000000000..f066d61531647 --- /dev/null +++ b/sandbox/plugins/dsl-query-executor/src/test/java/org/opensearch/dsl/action/TransportDslExecuteActionTests.java @@ -0,0 +1,171 @@ +/* + * 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.action; + +import org.apache.calcite.jdbc.CalciteSchema; +import org.apache.calcite.rel.type.RelDataType; +import org.apache.calcite.rel.type.RelDataTypeFactory; +import org.apache.calcite.schema.SchemaPlus; +import org.apache.calcite.schema.impl.AbstractTable; +import org.apache.calcite.sql.SqlOperatorTable; +import org.apache.calcite.sql.type.SqlTypeName; +import org.apache.calcite.sql.util.SqlOperatorTables; +import org.opensearch.action.search.SearchRequest; +import org.opensearch.action.search.SearchResponse; +import org.opensearch.action.support.ActionFilters; +import org.opensearch.analytics.EngineContext; +import org.opensearch.cluster.ClusterState; +import org.opensearch.cluster.metadata.IndexNameExpressionResolver; +import org.opensearch.cluster.service.ClusterService; +import org.opensearch.core.action.ActionListener; +import org.opensearch.core.index.Index; +import org.opensearch.index.IndexNotFoundException; +import org.opensearch.search.builder.SearchSourceBuilder; +import org.opensearch.tasks.Task; +import org.opensearch.test.OpenSearchTestCase; +import org.opensearch.transport.TransportService; + +import java.util.Collections; +import java.util.concurrent.atomic.AtomicReference; + +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class TransportDslExecuteActionTests extends OpenSearchTestCase { + + public void testDoExecuteReturnsSearchResponse() { + TransportDslExecuteAction action = createAction(new Index("test-index", "uuid")); + + TestListener listener = executeWith(action, "test-index"); + + assertNull("Expected no failure but got: " + listener.failure.get(), listener.failure.get()); + assertNotNull(listener.response.get()); + assertEquals(200, listener.response.get().status().getStatus()); + } + + public void testDoExecuteFailsWhenIndexNotInSchema() { + TransportDslExecuteAction action = createAction(new Index("nonexistent-index", "uuid")); + + TestListener listener = executeWith(action, "nonexistent-index"); + + assertNull(listener.response.get()); + assertNotNull(listener.failure.get()); + assertTrue(listener.failure.get() instanceof IllegalArgumentException); + assertTrue(listener.failure.get().getMessage().contains("nonexistent-index")); + } + + public void testDoExecuteRejectsMultipleConcreteIndices() { + TransportDslExecuteAction action = createAction( + new Index("index-a", "uuid-a"), + new Index("index-b", "uuid-b") + ); + + TestListener listener = executeWith(action, "multi-alias"); + + assertNull(listener.response.get()); + assertNotNull(listener.failure.get()); + assertTrue(listener.failure.get() instanceof IllegalArgumentException); + assertTrue(listener.failure.get().getMessage().contains("exactly one concrete index")); + } + + public void testDoExecuteFailsWhenIndexNotInClusterState() { + ClusterService clusterService = mock(ClusterService.class); + when(clusterService.state()).thenReturn(mock(ClusterState.class)); + + IndexNameExpressionResolver resolver = mock(IndexNameExpressionResolver.class); + when(resolver.concreteIndices(any(), any(SearchRequest.class))) + .thenThrow(new IndexNotFoundException("bogus-index")); + + TransportDslExecuteAction action = new TransportDslExecuteAction( + mock(TransportService.class), + new ActionFilters(Collections.emptySet()), + buildEngineContext(), + (plan, ctx) -> Collections.emptyList(), + clusterService, + resolver + ); + + TestListener listener = executeWith(action, "bogus-index"); + + assertNull(listener.response.get()); + assertNotNull(listener.failure.get()); + assertTrue(listener.failure.get() instanceof IndexNotFoundException); + } + + private TestListener executeWith(TransportDslExecuteAction action, String index) { + SearchRequest request = new SearchRequest(index); + request.source(new SearchSourceBuilder()); + + TestListener listener = new TestListener(); + action.doExecute(mock(Task.class), request, listener); + return listener; + } + + private TransportDslExecuteAction createAction(Index... resolvedIndices) { + ClusterService clusterService = mock(ClusterService.class); + when(clusterService.state()).thenReturn(mock(ClusterState.class)); + + IndexNameExpressionResolver resolver = mock(IndexNameExpressionResolver.class); + when(resolver.concreteIndices(any(), any(SearchRequest.class))).thenReturn(resolvedIndices); + + return new TransportDslExecuteAction( + mock(TransportService.class), + new ActionFilters(Collections.emptySet()), + buildEngineContext(), + (plan, ctx) -> Collections.emptyList(), + clusterService, + resolver + ); + } + + private EngineContext buildEngineContext() { + SchemaPlus schema = buildSchema(); + return new EngineContext() { + @Override + public SchemaPlus getSchema() { + return schema; + } + + @Override + public SqlOperatorTable operatorTable() { + return SqlOperatorTables.of(); + } + }; + } + + private SchemaPlus buildSchema() { + SchemaPlus schema = CalciteSchema.createRootSchema(true).plus(); + schema.add("test-index", new AbstractTable() { + @Override + public RelDataType getRowType(RelDataTypeFactory tf) { + return tf.builder() + .add("name", SqlTypeName.VARCHAR) + .add("price", SqlTypeName.INTEGER) + .build(); + } + }); + return schema; + } + + private static class TestListener implements ActionListener { + final AtomicReference response = new AtomicReference<>(); + final AtomicReference failure = new AtomicReference<>(); + + @Override + public void onResponse(SearchResponse r) { + response.set(r); + } + + @Override + public void onFailure(Exception e) { + failure.set(e); + } + } +} diff --git a/sandbox/plugins/dsl-query-executor/src/test/java/org/opensearch/dsl/aggregation/AggregationTreeWalkerTests.java b/sandbox/plugins/dsl-query-executor/src/test/java/org/opensearch/dsl/aggregation/AggregationTreeWalkerTests.java new file mode 100644 index 0000000000000..bf6583599c7ee --- /dev/null +++ b/sandbox/plugins/dsl-query-executor/src/test/java/org/opensearch/dsl/aggregation/AggregationTreeWalkerTests.java @@ -0,0 +1,152 @@ +/* + * 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.aggregation; + +import org.opensearch.dsl.TestUtils; +import org.opensearch.dsl.converter.ConversionContext; +import org.opensearch.dsl.converter.ConversionException; +import org.opensearch.search.aggregations.AggregationBuilder; +import org.opensearch.search.aggregations.bucket.histogram.HistogramAggregationBuilder; +import org.opensearch.search.aggregations.bucket.terms.TermsAggregationBuilder; +import org.opensearch.search.aggregations.metrics.AvgAggregationBuilder; +import org.opensearch.search.aggregations.metrics.SumAggregationBuilder; +import org.opensearch.test.OpenSearchTestCase; + +import java.util.List; + +public class AggregationTreeWalkerTests extends OpenSearchTestCase { + + private final AggregationTreeWalker walker = new AggregationTreeWalker(AggregationRegistryFactory.create()); + private final ConversionContext ctx = TestUtils.createContext(); + + public void testMetricOnly() throws ConversionException { + List aggs = List.of( + new AvgAggregationBuilder("avg_price").field("price") + ); + + List result = walker.walk(aggs, ctx.getRowType(), ctx.getCluster().getTypeFactory()); + + assertEquals(1, result.size()); + assertTrue(result.get(0).getGroupByBitSet().isEmpty()); + // No implicit _count for global (no-bucket) granularity + assertEquals(1, result.get(0).getAggregateCalls().size()); + assertEquals("avg_price", result.get(0).getAggregateFieldNames().get(0)); + } + + public void testMultipleMetricsSameGranularity() throws ConversionException { + List aggs = List.of( + new AvgAggregationBuilder("avg_price").field("price"), + new SumAggregationBuilder("total_price").field("price") + ); + + List result = walker.walk(aggs, ctx.getRowType(), ctx.getCluster().getTypeFactory()); + + assertEquals(1, result.size()); + assertEquals(2, result.get(0).getAggregateCalls().size()); + assertEquals(List.of("avg_price", "total_price"), result.get(0).getAggregateFieldNames()); + } + + public void testBucketWithMetric() throws ConversionException { + List aggs = List.of( + new TermsAggregationBuilder("by_brand").field("brand") + .subAggregation(new AvgAggregationBuilder("avg_price").field("price")) + ); + + List result = walker.walk(aggs, ctx.getRowType(), ctx.getCluster().getTypeFactory()); + + assertEquals(1, result.size()); + assertFalse(result.get(0).getGroupByBitSet().isEmpty()); + assertTrue(result.get(0).getGroupByBitSet().get(2)); // brand is index 2 + assertEquals(List.of("brand"), result.get(0).getGroupByFieldNames()); + // avg_price + implicit _count + assertEquals(2, result.get(0).getAggregateCalls().size()); + assertTrue(result.get(0).getAggregateFieldNames().contains("_count")); + assertTrue(result.get(0).getAggregateFieldNames().contains("avg_price")); + } + + public void testBucketOnlyProducesImplicitCount() throws ConversionException { + // terms bucket with no explicit metrics — still produces metadata with implicit _count + List aggs = List.of( + new TermsAggregationBuilder("by_brand").field("brand") + ); + + List result = walker.walk(aggs, ctx.getRowType(), ctx.getCluster().getTypeFactory()); + + assertEquals(1, result.size()); + assertEquals(1, result.get(0).getAggregateCalls().size()); + assertEquals("_count", result.get(0).getAggregateFieldNames().get(0)); + } + + public void testNestedBucketsProduceMultipleGranularities() throws ConversionException { + List aggs = List.of( + new TermsAggregationBuilder("by_brand").field("brand") + .subAggregation(new SumAggregationBuilder("total").field("price")) + .subAggregation( + new TermsAggregationBuilder("by_name").field("name") + .subAggregation(new AvgAggregationBuilder("avg_price").field("price")) + ) + ); + + List result = walker.walk(aggs, ctx.getRowType(), ctx.getCluster().getTypeFactory()); + + assertEquals(2, result.size()); + // Brand granularity: SUM + implicit _count + assertEquals(List.of("brand"), result.get(0).getGroupByFieldNames()); + assertEquals(2, result.get(0).getAggregateCalls().size()); + // Brand+name granularity: AVG + implicit _count + assertEquals(List.of("brand", "name"), result.get(1).getGroupByFieldNames()); + assertEquals(2, result.get(1).getAggregateCalls().size()); + } + + public void testMetricAtMultipleGranularities() throws ConversionException { + List aggs = List.of( + new AvgAggregationBuilder("global_avg").field("price"), + new TermsAggregationBuilder("by_brand").field("brand") + .subAggregation(new AvgAggregationBuilder("brand_avg").field("price")) + ); + + List result = walker.walk(aggs, ctx.getRowType(), ctx.getCluster().getTypeFactory()); + + assertEquals(2, result.size()); + // Root: no GROUP BY, no implicit _count + assertTrue(result.get(0).getGroupByBitSet().isEmpty()); + assertEquals(1, result.get(0).getAggregateCalls().size()); + assertEquals(List.of("global_avg"), result.get(0).getAggregateFieldNames()); + // Brand: GROUP BY brand, AVG + implicit _count + assertEquals(List.of("brand"), result.get(1).getGroupByFieldNames()); + assertEquals(2, result.get(1).getAggregateCalls().size()); + } + + public void testSiblingBucketsProduceSeparateGranularities() throws ConversionException { + List aggs = List.of( + new TermsAggregationBuilder("by_brand").field("brand") + .subAggregation(new AvgAggregationBuilder("brand_avg").field("price")), + new TermsAggregationBuilder("by_name").field("name") + .subAggregation(new SumAggregationBuilder("name_total").field("price")) + ); + + List result = walker.walk(aggs, ctx.getRowType(), ctx.getCluster().getTypeFactory()); + + assertEquals(2, result.size()); + // brand granularity: AVG + _count + assertEquals(List.of("brand"), result.get(0).getGroupByFieldNames()); + assertEquals(2, result.get(0).getAggregateCalls().size()); + // name granularity: SUM + _count + assertEquals(List.of("name"), result.get(1).getGroupByFieldNames()); + assertEquals(2, result.get(1).getAggregateCalls().size()); + } + + public void testThrowsForUnsupportedAggregation() { + List aggs = List.of( + new HistogramAggregationBuilder("by_price").field("price").interval(100) + ); + + expectThrows(ConversionException.class, () -> walker.walk(aggs, ctx.getRowType(), ctx.getCluster().getTypeFactory())); + } +} diff --git a/sandbox/plugins/dsl-query-executor/src/test/java/org/opensearch/dsl/aggregation/bucket/TermsBucketTranslatorTests.java b/sandbox/plugins/dsl-query-executor/src/test/java/org/opensearch/dsl/aggregation/bucket/TermsBucketTranslatorTests.java new file mode 100644 index 0000000000000..0017538b7617b --- /dev/null +++ b/sandbox/plugins/dsl-query-executor/src/test/java/org/opensearch/dsl/aggregation/bucket/TermsBucketTranslatorTests.java @@ -0,0 +1,61 @@ +/* + * 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.aggregation.bucket; + +import org.opensearch.dsl.TestUtils; +import org.opensearch.dsl.aggregation.GroupingInfo; +import org.opensearch.dsl.converter.ConversionContext; +import org.opensearch.dsl.converter.ConversionException; +import org.opensearch.search.aggregations.bucket.terms.TermsAggregationBuilder; +import org.opensearch.search.aggregations.metrics.AvgAggregationBuilder; +import org.opensearch.test.OpenSearchTestCase; + +import java.util.List; + +public class TermsBucketTranslatorTests extends OpenSearchTestCase { + + private final TermsBucketTranslator translator = new TermsBucketTranslator(); + private final ConversionContext ctx = TestUtils.createContext(); + private final TermsAggregationBuilder brandAgg = new TermsAggregationBuilder("by_brand").field("brand"); + + public void testGetGrouping() { + assertEquals(List.of("brand"), translator.getGrouping(brandAgg).getFieldNames()); + } + + public void testResolveGroupByIndices() throws ConversionException { + List indices = translator.getGrouping(brandAgg).resolveIndices(ctx.getRowType()); + + assertEquals(List.of(2), indices); // brand is index 2 + } + + public void testGetSubAggregations() { + TermsAggregationBuilder aggWithSub = new TermsAggregationBuilder("by_brand").field("brand") + .subAggregation(new AvgAggregationBuilder("avg_price").field("price")); + + assertEquals(1, translator.getSubAggregations(aggWithSub).size()); + } + + public void testEmptySubAggregations() { + assertTrue(translator.getSubAggregations(brandAgg).isEmpty()); + } + + public void testReportsCorrectType() { + assertEquals(TermsAggregationBuilder.class, translator.getAggregationType()); + } + + public void testThrowsForUnknownField() { + TermsAggregationBuilder badAgg = new TermsAggregationBuilder("by_bad").field("nonexistent"); + + expectThrows(ConversionException.class, () -> translator.getGrouping(badAgg).resolveIndices(ctx.getRowType())); + } + + public void testToBucketAggregationNotYetImplemented() { + expectThrows(UnsupportedOperationException.class, () -> translator.toBucketAggregation(brandAgg, List.of())); + } +} diff --git a/sandbox/plugins/dsl-query-executor/src/test/java/org/opensearch/dsl/aggregation/metric/MetricTranslatorTests.java b/sandbox/plugins/dsl-query-executor/src/test/java/org/opensearch/dsl/aggregation/metric/MetricTranslatorTests.java new file mode 100644 index 0000000000000..31366af9590cc --- /dev/null +++ b/sandbox/plugins/dsl-query-executor/src/test/java/org/opensearch/dsl/aggregation/metric/MetricTranslatorTests.java @@ -0,0 +1,71 @@ +/* + * 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.aggregation.metric; + +import org.apache.calcite.rel.core.AggregateCall; +import org.apache.calcite.sql.SqlKind; +import org.opensearch.dsl.TestUtils; +import org.opensearch.dsl.converter.ConversionContext; +import org.opensearch.dsl.converter.ConversionException; +import org.opensearch.search.aggregations.metrics.AvgAggregationBuilder; +import org.opensearch.search.aggregations.metrics.MaxAggregationBuilder; +import org.opensearch.search.aggregations.metrics.MinAggregationBuilder; +import org.opensearch.search.aggregations.metrics.SumAggregationBuilder; +import org.opensearch.test.OpenSearchTestCase; + +public class MetricTranslatorTests extends OpenSearchTestCase { + + private final ConversionContext ctx = TestUtils.createContext(); + + public void testAvgTranslator() throws ConversionException { + AvgMetricTranslator translator = new AvgMetricTranslator(); + AggregateCall call = translator.toAggregateCall(new AvgAggregationBuilder("avg_price").field("price"), ctx.getRowType()); + + assertEquals(SqlKind.AVG, call.getAggregation().getKind()); + assertEquals("avg_price", call.getName()); + assertEquals(1, call.getArgList().size()); + assertEquals(1, call.getArgList().get(0).intValue()); // price is index 1 + } + + public void testSumTranslator() throws ConversionException { + SumMetricTranslator translator = new SumMetricTranslator(); + AggregateCall call = translator.toAggregateCall(new SumAggregationBuilder("total").field("price"), ctx.getRowType()); + + assertEquals(SqlKind.SUM, call.getAggregation().getKind()); + assertEquals("total", call.getName()); + } + + public void testMinTranslator() throws ConversionException { + MinMetricTranslator translator = new MinMetricTranslator(); + AggregateCall call = translator.toAggregateCall(new MinAggregationBuilder("min_price").field("price"), ctx.getRowType()); + + assertEquals(SqlKind.MIN, call.getAggregation().getKind()); + assertEquals("min_price", call.getName()); + } + + public void testMaxTranslator() throws ConversionException { + MaxMetricTranslator translator = new MaxMetricTranslator(); + AggregateCall call = translator.toAggregateCall(new MaxAggregationBuilder("max_price").field("price"), ctx.getRowType()); + + assertEquals(SqlKind.MAX, call.getAggregation().getKind()); + assertEquals("max_price", call.getName()); + } + + public void testThrowsForUnknownField() { + AvgMetricTranslator translator = new AvgMetricTranslator(); + + expectThrows(ConversionException.class, + () -> translator.toAggregateCall(new AvgAggregationBuilder("bad").field("nonexistent"), ctx.getRowType())); + } + + public void testAggregateFieldName() { + AvgMetricTranslator translator = new AvgMetricTranslator(); + assertEquals("avg_price", translator.getAggregateFieldName(new AvgAggregationBuilder("avg_price").field("price"))); + } +} diff --git a/sandbox/plugins/dsl-query-executor/src/test/java/org/opensearch/dsl/converter/AggregateConverterTests.java b/sandbox/plugins/dsl-query-executor/src/test/java/org/opensearch/dsl/converter/AggregateConverterTests.java new file mode 100644 index 0000000000000..82f5aa7edba84 --- /dev/null +++ b/sandbox/plugins/dsl-query-executor/src/test/java/org/opensearch/dsl/converter/AggregateConverterTests.java @@ -0,0 +1,73 @@ +/* + * 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.converter; + +import org.apache.calcite.rel.RelNode; +import org.apache.calcite.rel.logical.LogicalAggregate; +import org.apache.calcite.rel.logical.LogicalTableScan; +import org.opensearch.dsl.TestUtils; +import org.opensearch.dsl.aggregation.AggregationMetadata; +import org.opensearch.dsl.aggregation.AggregationRegistryFactory; +import org.opensearch.dsl.aggregation.AggregationTreeWalker; +import org.opensearch.search.aggregations.AggregationBuilder; +import org.opensearch.search.aggregations.bucket.terms.TermsAggregationBuilder; +import org.opensearch.search.aggregations.metrics.AvgAggregationBuilder; +import org.opensearch.test.OpenSearchTestCase; + +import java.util.List; + +public class AggregateConverterTests extends OpenSearchTestCase { + + private final AggregateConverter converter = new AggregateConverter(); + private final AggregationTreeWalker walker = new AggregationTreeWalker(AggregationRegistryFactory.create()); + private final LogicalTableScan scan = TestUtils.createTestRelNode(); + + public void testMetricOnlyProducesAggregateWithNoGroupBy() throws ConversionException { + List metadataList = walker.walk( + List.of(new AvgAggregationBuilder("avg_price").field("price")), + scan.getRowType(), scan.getCluster().getTypeFactory() + ); + + RelNode result = converter.convert(scan, metadataList.get(0)); + + assertTrue(result instanceof LogicalAggregate); + LogicalAggregate agg = (LogicalAggregate) result; + assertTrue(agg.getGroupSet().isEmpty()); + assertEquals(1, agg.getAggCallList().size()); + } + + public void testBucketWithMetricProducesGroupBy() throws ConversionException { + List metadataList = walker.walk( + List.of( + new TermsAggregationBuilder("by_brand").field("brand") + .subAggregation(new AvgAggregationBuilder("avg_price").field("price")) + ), + scan.getRowType(), scan.getCluster().getTypeFactory() + ); + + RelNode result = converter.convert(scan, metadataList.get(0)); + + assertTrue(result instanceof LogicalAggregate); + LogicalAggregate agg = (LogicalAggregate) result; + assertTrue(agg.getGroupSet().get(2)); // brand is index 2 + assertEquals(2, agg.getAggCallList().size()); // avg_price + implicit _count + } + + public void testInputIsScan() throws ConversionException { + List metadataList = walker.walk( + List.of(new AvgAggregationBuilder("avg_price").field("price")), + scan.getRowType(), scan.getCluster().getTypeFactory() + ); + + RelNode result = converter.convert(scan, metadataList.get(0)); + LogicalAggregate agg = (LogicalAggregate) result; + + assertSame(scan, agg.getInput()); + } +} diff --git a/sandbox/plugins/dsl-query-executor/src/test/java/org/opensearch/dsl/converter/FilterConverterTests.java b/sandbox/plugins/dsl-query-executor/src/test/java/org/opensearch/dsl/converter/FilterConverterTests.java new file mode 100644 index 0000000000000..6f4fa80afa5b0 --- /dev/null +++ b/sandbox/plugins/dsl-query-executor/src/test/java/org/opensearch/dsl/converter/FilterConverterTests.java @@ -0,0 +1,84 @@ +/* + * 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.converter; + +import org.apache.calcite.rel.RelNode; +import org.apache.calcite.rel.logical.LogicalFilter; +import org.apache.calcite.rel.logical.LogicalTableScan; +import org.apache.calcite.rex.RexCall; +import org.apache.calcite.rex.RexInputRef; +import org.apache.calcite.sql.SqlKind; +import org.opensearch.dsl.TestUtils; +import org.opensearch.dsl.query.QueryRegistryFactory; +import org.opensearch.index.query.QueryBuilders; +import org.opensearch.search.builder.SearchSourceBuilder; +import org.opensearch.test.OpenSearchTestCase; + +public class FilterConverterTests extends OpenSearchTestCase { + + private final FilterConverter converter = new FilterConverter(QueryRegistryFactory.create()); + private final LogicalTableScan scan = TestUtils.createTestRelNode(); + + public void testSkipsWhenNoQuery() throws ConversionException { + ConversionContext ctx = TestUtils.createContext(new SearchSourceBuilder()); + RelNode result = converter.convert(scan, ctx); + + assertSame(scan, result); + } + + public void testSkipsMatchAll() throws ConversionException { + SearchSourceBuilder source = new SearchSourceBuilder().query(QueryBuilders.matchAllQuery()); + ConversionContext ctx = TestUtils.createContext(source); + RelNode result = converter.convert(scan, ctx); + + assertSame(scan, result); + } + + public void testTermQueryProducesLogicalFilter() throws ConversionException { + SearchSourceBuilder source = new SearchSourceBuilder().query(QueryBuilders.termQuery("name", "laptop")); + ConversionContext ctx = TestUtils.createContext(source); + RelNode result = converter.convert(scan, ctx); + + assertTrue(result instanceof LogicalFilter); + LogicalFilter filter = (LogicalFilter) result; + + // Input should be the original scan + assertSame(scan, filter.getInput()); + + // Condition should be: name = 'laptop' + assertTrue(filter.getCondition() instanceof RexCall); + RexCall call = (RexCall) filter.getCondition(); + assertEquals(SqlKind.EQUALS, call.getKind()); + + assertEquals(2, call.getOperands().size()); + // Left operand: field reference to 'name' (index 0) + assertTrue(call.getOperands().get(0) instanceof RexInputRef); + assertEquals(0, ((RexInputRef) call.getOperands().get(0)).getIndex()); + } + + public void testFilterPreservesRowType() throws ConversionException { + SearchSourceBuilder source = new SearchSourceBuilder().query(QueryBuilders.termQuery("brand", "acme")); + ConversionContext ctx = TestUtils.createContext(source); + RelNode result = converter.convert(scan, ctx); + + // Filter doesn't change the row type — same fields as the scan + assertEquals(scan.getRowType(), result.getRowType()); + } + + public void testUnsupportedQueryProducesFilterWithUnresolvedCondition() throws ConversionException { + SearchSourceBuilder source = new SearchSourceBuilder().query(QueryBuilders.wildcardQuery("name", "lap*")); + ConversionContext ctx = TestUtils.createContext(source); + RelNode result = converter.convert(scan, ctx); + + // Unsupported query types produce a LogicalFilter with an UnresolvedQueryCall condition + assertTrue(result instanceof LogicalFilter); + LogicalFilter filter = (LogicalFilter) result; + assertTrue(filter.getCondition() instanceof org.opensearch.dsl.query.UnresolvedQueryCall); + } +} diff --git a/sandbox/plugins/dsl-query-executor/src/test/java/org/opensearch/dsl/converter/ProjectConverterTests.java b/sandbox/plugins/dsl-query-executor/src/test/java/org/opensearch/dsl/converter/ProjectConverterTests.java new file mode 100644 index 0000000000000..53e2bd4a95c86 --- /dev/null +++ b/sandbox/plugins/dsl-query-executor/src/test/java/org/opensearch/dsl/converter/ProjectConverterTests.java @@ -0,0 +1,115 @@ +/* + * 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.converter; + +import org.apache.calcite.rel.RelNode; +import org.apache.calcite.rel.logical.LogicalProject; +import org.apache.calcite.rel.logical.LogicalTableScan; +import org.opensearch.dsl.TestUtils; + +import org.opensearch.search.builder.SearchSourceBuilder; +import org.opensearch.search.fetch.subphase.FetchSourceContext; +import org.opensearch.test.OpenSearchTestCase; + +public class ProjectConverterTests extends OpenSearchTestCase { + + private final ProjectConverter converter = new ProjectConverter(); + private final LogicalTableScan scan = TestUtils.createTestRelNode(); + + public void testSkipsWhenNoSourceFiltering() throws ConversionException { + ConversionContext ctx = TestUtils.createContext(new SearchSourceBuilder()); + RelNode result = converter.convert(scan, ctx); + + assertSame(scan, result); + } + + public void testProjectsSpecificFields() throws ConversionException { + SearchSourceBuilder source = new SearchSourceBuilder() + .fetchSource(new FetchSourceContext(true, new String[]{"name", "price"}, null)); + ConversionContext ctx = TestUtils.createContext(source); + RelNode result = converter.convert(scan, ctx); + + assertTrue(result instanceof LogicalProject); + assertEquals(2, result.getRowType().getFieldCount()); + assertEquals("name", result.getRowType().getFieldNames().get(0)); + assertEquals("price", result.getRowType().getFieldNames().get(1)); + } + + public void testEmptyProjectionWhenFetchSourceFalse() throws ConversionException { + SearchSourceBuilder source = new SearchSourceBuilder() + .fetchSource(new FetchSourceContext(false, null, null)); + ConversionContext ctx = TestUtils.createContext(source); + RelNode result = converter.convert(scan, ctx); + + assertTrue(result instanceof LogicalProject); + assertEquals(0, result.getRowType().getFieldCount()); + } + + public void testReturnsUnchangedWhenIncludesEmpty() throws ConversionException { + SearchSourceBuilder source = new SearchSourceBuilder() + .fetchSource(new FetchSourceContext(true, new String[]{}, null)); + ConversionContext ctx = TestUtils.createContext(source); + RelNode result = converter.convert(scan, ctx); + + assertSame(scan, result); + } + + public void testThrowsForUnknownField() { + SearchSourceBuilder source = new SearchSourceBuilder() + .fetchSource(new FetchSourceContext(true, new String[]{"nonexistent"}, null)); + ConversionContext ctx = TestUtils.createContext(source); + + expectThrows(ConversionException.class, () -> converter.convert(scan, ctx)); + } + + public void testWildcardProjection() throws ConversionException { + SearchSourceBuilder source = new SearchSourceBuilder() + .fetchSource(new FetchSourceContext(true, new String[]{"na*"}, null)); + ConversionContext ctx = TestUtils.createContext(source); + RelNode result = converter.convert(scan, ctx); + + assertTrue(result instanceof LogicalProject); + assertEquals(1, result.getRowType().getFieldCount()); + assertEquals("name", result.getRowType().getFieldNames().get(0)); + } + + public void testExcludesFields() throws ConversionException { + SearchSourceBuilder source = new SearchSourceBuilder() + .fetchSource(new FetchSourceContext(true, null, new String[]{"price", "rating"})); + ConversionContext ctx = TestUtils.createContext(source); + RelNode result = converter.convert(scan, ctx); + + assertTrue(result instanceof LogicalProject); + assertEquals(2, result.getRowType().getFieldCount()); + assertEquals("name", result.getRowType().getFieldNames().get(0)); + assertEquals("brand", result.getRowType().getFieldNames().get(1)); + } + + public void testExcludesWithWildcard() throws ConversionException { + SearchSourceBuilder source = new SearchSourceBuilder() + .fetchSource(new FetchSourceContext(true, null, new String[]{"ra*"})); + ConversionContext ctx = TestUtils.createContext(source); + RelNode result = converter.convert(scan, ctx); + + assertTrue(result instanceof LogicalProject); + assertEquals(3, result.getRowType().getFieldCount()); + assertFalse(result.getRowType().getFieldNames().contains("rating")); + } + + public void testWildcardNoMatchReturnsEmptyProjection() throws ConversionException { + SearchSourceBuilder source = new SearchSourceBuilder() + .fetchSource(new FetchSourceContext(true, new String[]{"xyz*"}, null)); + ConversionContext ctx = TestUtils.createContext(source); + RelNode result = converter.convert(scan, ctx); + + // Consistent with OpenSearch core — no error, just empty _source + assertTrue(result instanceof LogicalProject); + assertEquals(0, result.getRowType().getFieldCount()); + } +} diff --git a/sandbox/plugins/dsl-query-executor/src/test/java/org/opensearch/dsl/converter/SearchSourceConverterTests.java b/sandbox/plugins/dsl-query-executor/src/test/java/org/opensearch/dsl/converter/SearchSourceConverterTests.java new file mode 100644 index 0000000000000..ca90051796674 --- /dev/null +++ b/sandbox/plugins/dsl-query-executor/src/test/java/org/opensearch/dsl/converter/SearchSourceConverterTests.java @@ -0,0 +1,101 @@ +/* + * 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.converter; + +import org.apache.calcite.jdbc.CalciteSchema; +import org.apache.calcite.rel.logical.LogicalTableScan; +import org.apache.calcite.rel.type.RelDataType; +import org.apache.calcite.rel.type.RelDataTypeFactory; +import org.apache.calcite.schema.SchemaPlus; +import org.apache.calcite.schema.impl.AbstractTable; +import org.apache.calcite.sql.type.SqlTypeName; +import org.opensearch.dsl.executor.QueryPlans; +import org.opensearch.search.aggregations.AggregatorFactories; +import org.opensearch.search.aggregations.metrics.AvgAggregationBuilder; +import org.opensearch.search.builder.SearchSourceBuilder; +import org.opensearch.test.OpenSearchTestCase; + +import java.util.List; + +public class SearchSourceConverterTests extends OpenSearchTestCase { + + private SearchSourceConverter converter; + + @Override + public void setUp() throws Exception { + super.setUp(); + SchemaPlus schema = CalciteSchema.createRootSchema(true).plus(); + schema.add("test-index", new AbstractTable() { + @Override + public RelDataType getRowType(RelDataTypeFactory typeFactory) { + // Nullable fields — matches OpenSearchSchemaBuilder behavior + return typeFactory.builder() + .add("name", typeFactory.createTypeWithNullability(typeFactory.createSqlType(SqlTypeName.VARCHAR), true)) + .add("price", typeFactory.createTypeWithNullability(typeFactory.createSqlType(SqlTypeName.INTEGER), true)) + .add("brand", typeFactory.createTypeWithNullability(typeFactory.createSqlType(SqlTypeName.VARCHAR), true)) + .add("rating", typeFactory.createTypeWithNullability(typeFactory.createSqlType(SqlTypeName.DOUBLE), true)) + .build(); + } + }); + converter = new SearchSourceConverter(schema); + } + + public void testConvertProducesHitsPlan() throws ConversionException { + QueryPlans plans = converter.convert(new SearchSourceBuilder(), "test-index"); + + assertEquals(1, plans.getAll().size()); + assertTrue(plans.has(QueryPlans.Type.HITS)); + + QueryPlans.QueryPlan plan = plans.get(QueryPlans.Type.HITS).get(0); + assertTrue(plan.relNode() instanceof LogicalTableScan); + } + + public void testConvertResolvesFieldNames() throws ConversionException { + QueryPlans plans = converter.convert(new SearchSourceBuilder(), "test-index"); + + QueryPlans.QueryPlan plan = plans.get(QueryPlans.Type.HITS).get(0); + assertEquals(4, plan.relNode().getRowType().getFieldCount()); + assertEquals(List.of("name", "price", "brand", "rating"), plan.relNode().getRowType().getFieldNames()); + } + + public void testConvertThrowsForMissingIndex() { + expectThrows(IllegalArgumentException.class, + () -> converter.convert(new SearchSourceBuilder(), "nonexistent-index")); + } + + public void testAggsWithSizeZeroProducesOnlyAggregationPlan() throws ConversionException { + SearchSourceBuilder source = new SearchSourceBuilder() + .size(0) + .aggregation(new AvgAggregationBuilder("avg_price").field("price")); + QueryPlans plans = converter.convert(source, "test-index"); + + assertEquals(1, plans.getAll().size()); + assertFalse(plans.has(QueryPlans.Type.HITS)); + assertTrue(plans.has(QueryPlans.Type.AGGREGATION)); + } + + public void testAggsWithSizeGreaterThanZeroProducesBothPlans() throws ConversionException { + SearchSourceBuilder source = new SearchSourceBuilder() + .size(10) + .aggregation(new AvgAggregationBuilder("avg_price").field("price")); + QueryPlans plans = converter.convert(source, "test-index"); + + assertEquals(2, plans.getAll().size()); + assertTrue(plans.has(QueryPlans.Type.HITS)); + assertTrue(plans.has(QueryPlans.Type.AGGREGATION)); + } + + public void testNoAggsProducesOnlyHitsPlan() throws ConversionException { + QueryPlans plans = converter.convert(new SearchSourceBuilder(), "test-index"); + + assertEquals(1, plans.getAll().size()); + assertTrue(plans.has(QueryPlans.Type.HITS)); + assertFalse(plans.has(QueryPlans.Type.AGGREGATION)); + } +} diff --git a/sandbox/plugins/dsl-query-executor/src/test/java/org/opensearch/dsl/converter/SortConverterTests.java b/sandbox/plugins/dsl-query-executor/src/test/java/org/opensearch/dsl/converter/SortConverterTests.java new file mode 100644 index 0000000000000..fbe43980a550f --- /dev/null +++ b/sandbox/plugins/dsl-query-executor/src/test/java/org/opensearch/dsl/converter/SortConverterTests.java @@ -0,0 +1,105 @@ +/* + * 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.converter; + +import org.apache.calcite.rel.RelCollation; +import org.apache.calcite.rel.RelFieldCollation; +import org.apache.calcite.rel.RelNode; +import org.apache.calcite.rel.logical.LogicalSort; +import org.apache.calcite.rel.logical.LogicalTableScan; +import org.apache.calcite.rex.RexLiteral; +import org.opensearch.dsl.TestUtils; +import org.opensearch.search.builder.SearchSourceBuilder; +import org.opensearch.search.sort.SortOrder; +import org.opensearch.test.OpenSearchTestCase; + +public class SortConverterTests extends OpenSearchTestCase { + + private final SortConverter converter = new SortConverter(); + private final LogicalTableScan scan = TestUtils.createTestRelNode(); + + public void testSkipsWhenDefaultPagination() throws ConversionException { + ConversionContext ctx = TestUtils.createContext(new SearchSourceBuilder()); + RelNode result = converter.convert(scan, ctx); + + assertSame(scan, result); + } + + public void testSortDescending() throws ConversionException { + SearchSourceBuilder source = new SearchSourceBuilder().sort("price", SortOrder.DESC); + ConversionContext ctx = TestUtils.createContext(source); + RelNode result = converter.convert(scan, ctx); + + assertTrue(result instanceof LogicalSort); + LogicalSort sort = (LogicalSort) result; + + RelCollation collation = sort.getCollation(); + assertEquals(1, collation.getFieldCollations().size()); + + RelFieldCollation fieldCollation = collation.getFieldCollations().get(0); + assertEquals(1, fieldCollation.getFieldIndex()); // price is index 1 + assertEquals(RelFieldCollation.Direction.DESCENDING, fieldCollation.getDirection()); + assertEquals(RelFieldCollation.NullDirection.FIRST, fieldCollation.nullDirection); + } + + public void testSortAscending() throws ConversionException { + SearchSourceBuilder source = new SearchSourceBuilder().sort("name", SortOrder.ASC); + ConversionContext ctx = TestUtils.createContext(source); + RelNode result = converter.convert(scan, ctx); + + LogicalSort sort = (LogicalSort) result; + RelFieldCollation fieldCollation = sort.getCollation().getFieldCollations().get(0); + assertEquals(0, fieldCollation.getFieldIndex()); // name is index 0 + assertEquals(RelFieldCollation.Direction.ASCENDING, fieldCollation.getDirection()); + assertEquals(RelFieldCollation.NullDirection.LAST, fieldCollation.nullDirection); + } + + public void testMultipleSortFields() throws ConversionException { + SearchSourceBuilder source = new SearchSourceBuilder() + .sort("brand", SortOrder.ASC) + .sort("price", SortOrder.DESC); + ConversionContext ctx = TestUtils.createContext(source); + RelNode result = converter.convert(scan, ctx); + + LogicalSort sort = (LogicalSort) result; + assertEquals(2, sort.getCollation().getFieldCollations().size()); + assertEquals(2, sort.getCollation().getFieldCollations().get(0).getFieldIndex()); // brand + assertEquals(1, sort.getCollation().getFieldCollations().get(1).getFieldIndex()); // price + } + + public void testCustomSize() throws ConversionException { + SearchSourceBuilder source = new SearchSourceBuilder().size(5); + ConversionContext ctx = TestUtils.createContext(source); + RelNode result = converter.convert(scan, ctx); + + LogicalSort sort = (LogicalSort) result; + assertNull(sort.offset); // from defaults to 0 → no offset + assertNotNull(sort.fetch); + assertEquals(5, RexLiteral.intValue(sort.fetch)); + } + + public void testFromAndSize() throws ConversionException { + SearchSourceBuilder source = new SearchSourceBuilder().from(10).size(5); + ConversionContext ctx = TestUtils.createContext(source); + RelNode result = converter.convert(scan, ctx); + + LogicalSort sort = (LogicalSort) result; + assertNotNull(sort.offset); + assertNotNull(sort.fetch); + assertEquals(10, RexLiteral.intValue(sort.offset)); + assertEquals(5, RexLiteral.intValue(sort.fetch)); + } + + public void testThrowsForUnknownSortField() { + SearchSourceBuilder source = new SearchSourceBuilder().sort("nonexistent", SortOrder.ASC); + ConversionContext ctx = TestUtils.createContext(source); + + expectThrows(ConversionException.class, () -> converter.convert(scan, ctx)); + } +} diff --git a/sandbox/plugins/dsl-query-executor/src/test/java/org/opensearch/dsl/executor/DslQueryPlanExecutorTests.java b/sandbox/plugins/dsl-query-executor/src/test/java/org/opensearch/dsl/executor/DslQueryPlanExecutorTests.java new file mode 100644 index 0000000000000..0b0dc60de119d --- /dev/null +++ b/sandbox/plugins/dsl-query-executor/src/test/java/org/opensearch/dsl/executor/DslQueryPlanExecutorTests.java @@ -0,0 +1,50 @@ +/* + * 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.executor; + +import org.apache.calcite.rel.logical.LogicalTableScan; +import org.opensearch.dsl.TestUtils; +import org.opensearch.dsl.result.ExecutionResult; +import org.opensearch.test.OpenSearchTestCase; + +import java.util.Collections; +import java.util.List; + +public class DslQueryPlanExecutorTests extends OpenSearchTestCase { + + private LogicalTableScan scan; + + @Override + public void setUp() throws Exception { + super.setUp(); + scan = TestUtils.createTestRelNode(); + } + + public void testExecuteDelegatesEachPlanToExecutor() { + List expectedRows = List.of(new Object[]{"laptop", 1200}); + + DslQueryPlanExecutor executor = new DslQueryPlanExecutor((plan, ctx) -> expectedRows); + QueryPlans plans = new QueryPlans.Builder() + .add(new QueryPlans.QueryPlan(QueryPlans.Type.HITS, scan)) + .build(); + + List results = executor.execute(plans); + + assertEquals(1, results.size()); + ExecutionResult result = results.get(0); + assertSame(expectedRows, result.getRows()); + assertEquals(QueryPlans.Type.HITS, result.getType()); + assertNotNull(result.getPlan()); + assertSame(scan, result.getPlan().relNode()); + assertEquals(List.of("name", "price", "brand", "rating"), result.getFieldNames()); + } + + // TODO: add test with multiple plans (HITS + AGGREGATION) to verify iteration order + // TODO: add test for executor failure +} diff --git a/sandbox/plugins/dsl-query-executor/src/test/java/org/opensearch/dsl/executor/QueryPlansTests.java b/sandbox/plugins/dsl-query-executor/src/test/java/org/opensearch/dsl/executor/QueryPlansTests.java new file mode 100644 index 0000000000000..6bed4f3563110 --- /dev/null +++ b/sandbox/plugins/dsl-query-executor/src/test/java/org/opensearch/dsl/executor/QueryPlansTests.java @@ -0,0 +1,82 @@ +/* + * 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.executor; + +import org.apache.calcite.rel.RelNode; +import org.opensearch.dsl.TestUtils; +import org.opensearch.test.OpenSearchTestCase; + +public class QueryPlansTests extends OpenSearchTestCase { + + private RelNode relNode; + + @Override + public void setUp() throws Exception { + super.setUp(); + relNode = TestUtils.createTestRelNode(); + } + + public void testBuilderCreatesSinglePlan() { + QueryPlans plans = new QueryPlans.Builder() + .add(new QueryPlans.QueryPlan(QueryPlans.Type.HITS, relNode)) + .build(); + + assertEquals(1, plans.getAll().size()); + assertTrue(plans.has(QueryPlans.Type.HITS)); + assertFalse(plans.has(QueryPlans.Type.AGGREGATION)); + } + + public void testBuilderCreatesMultiplePlans() { + QueryPlans plans = new QueryPlans.Builder() + .add(new QueryPlans.QueryPlan(QueryPlans.Type.HITS, relNode)) + .add(new QueryPlans.QueryPlan(QueryPlans.Type.AGGREGATION, relNode)) + .build(); + + assertEquals(2, plans.getAll().size()); + assertTrue(plans.has(QueryPlans.Type.HITS)); + assertTrue(plans.has(QueryPlans.Type.AGGREGATION)); + assertEquals(1, plans.get(QueryPlans.Type.HITS).size()); + assertEquals(1, plans.get(QueryPlans.Type.AGGREGATION).size()); + } + + public void testGetReturnsMultiplePlansOfSameType() { + QueryPlans plans = new QueryPlans.Builder() + .add(new QueryPlans.QueryPlan(QueryPlans.Type.AGGREGATION, relNode)) + .add(new QueryPlans.QueryPlan(QueryPlans.Type.AGGREGATION, relNode)) + .build(); + + assertEquals(2, plans.get(QueryPlans.Type.AGGREGATION).size()); + } + + public void testBuilderThrowsOnEmpty() { + expectThrows(IllegalStateException.class, () -> new QueryPlans.Builder().build()); + } + + public void testGetReturnsEmptyForMissingType() { + QueryPlans plans = new QueryPlans.Builder() + .add(new QueryPlans.QueryPlan(QueryPlans.Type.HITS, relNode)) + .build(); + + assertTrue(plans.get(QueryPlans.Type.AGGREGATION).isEmpty()); + } + + public void testPlansAreImmutable() { + QueryPlans plans = new QueryPlans.Builder() + .add(new QueryPlans.QueryPlan(QueryPlans.Type.HITS, relNode)) + .build(); + + expectThrows(UnsupportedOperationException.class, + () -> plans.getAll().add(new QueryPlans.QueryPlan(QueryPlans.Type.AGGREGATION, relNode))); + } + + public void testQueryPlanRejectsNullArguments() { + expectThrows(NullPointerException.class, () -> new QueryPlans.QueryPlan(QueryPlans.Type.HITS, null)); + expectThrows(NullPointerException.class, () -> new QueryPlans.QueryPlan(null, relNode)); + } +} diff --git a/sandbox/plugins/dsl-query-executor/src/test/java/org/opensearch/dsl/query/MatchAllQueryTranslatorTests.java b/sandbox/plugins/dsl-query-executor/src/test/java/org/opensearch/dsl/query/MatchAllQueryTranslatorTests.java new file mode 100644 index 0000000000000..a2f820298929e --- /dev/null +++ b/sandbox/plugins/dsl-query-executor/src/test/java/org/opensearch/dsl/query/MatchAllQueryTranslatorTests.java @@ -0,0 +1,35 @@ +/* + * 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.RexLiteral; +import org.apache.calcite.rex.RexNode; +import org.opensearch.dsl.TestUtils; +import org.opensearch.dsl.converter.ConversionContext; +import org.opensearch.index.query.MatchAllQueryBuilder; +import org.opensearch.index.query.QueryBuilders; +import org.opensearch.test.OpenSearchTestCase; + +public class MatchAllQueryTranslatorTests extends OpenSearchTestCase { + + public void testConvertsMatchAllToTrueLiteral() { + ConversionContext ctx = TestUtils.createContext(); + MatchAllQueryTranslator translator = new MatchAllQueryTranslator(); + + RexNode result = translator.convert(QueryBuilders.matchAllQuery(), ctx); + + assertTrue(result instanceof RexLiteral); + assertTrue(RexLiteral.booleanValue(result)); + } + + public void testReportsCorrectQueryType() { + MatchAllQueryTranslator translator = new MatchAllQueryTranslator(); + assertEquals(MatchAllQueryBuilder.class, translator.getQueryType()); + } +} diff --git a/sandbox/plugins/dsl-query-executor/src/test/java/org/opensearch/dsl/query/QueryRegistryTests.java b/sandbox/plugins/dsl-query-executor/src/test/java/org/opensearch/dsl/query/QueryRegistryTests.java new file mode 100644 index 0000000000000..c90a3df9b1559 --- /dev/null +++ b/sandbox/plugins/dsl-query-executor/src/test/java/org/opensearch/dsl/query/QueryRegistryTests.java @@ -0,0 +1,52 @@ +/* + * 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.RexNode; +import org.opensearch.dsl.TestUtils; +import org.opensearch.dsl.converter.ConversionContext; +import org.opensearch.dsl.converter.ConversionException; +import org.opensearch.index.query.QueryBuilders; +import org.opensearch.index.query.WildcardQueryBuilder; +import org.opensearch.test.OpenSearchTestCase; + +public class QueryRegistryTests extends OpenSearchTestCase { + + private final QueryRegistry registry = QueryRegistryFactory.create(); + private final ConversionContext ctx = TestUtils.createContext(); + + public void testResolvesTermQuery() throws ConversionException { + RexNode result = registry.convert(QueryBuilders.termQuery("name", "laptop"), ctx); + + assertNotNull(result); + assertFalse(result instanceof UnresolvedQueryCall); + } + + public void testResolvesMatchAllQuery() throws ConversionException { + RexNode result = registry.convert(QueryBuilders.matchAllQuery(), ctx); + + assertNotNull(result); + assertFalse(result instanceof UnresolvedQueryCall); + } + + public void testUnknownQueryTypeReturnsUnresolved() throws ConversionException { + RexNode result = registry.convert(QueryBuilders.wildcardQuery("name", "lap*"), ctx); + + assertTrue(result instanceof UnresolvedQueryCall); + UnresolvedQueryCall unresolved = (UnresolvedQueryCall) result; + assertTrue(unresolved.getQueryBuilder() instanceof WildcardQueryBuilder); + } + + public void testEmptyRegistryReturnsUnresolvedForAnyQuery() throws ConversionException { + QueryRegistry empty = new QueryRegistry(); + + assertTrue(empty.convert(QueryBuilders.termQuery("name", "laptop"), ctx) instanceof UnresolvedQueryCall); + assertTrue(empty.convert(QueryBuilders.matchAllQuery(), ctx) instanceof UnresolvedQueryCall); + } +} diff --git a/sandbox/plugins/dsl-query-executor/src/test/java/org/opensearch/dsl/query/TermQueryTranslatorTests.java b/sandbox/plugins/dsl-query-executor/src/test/java/org/opensearch/dsl/query/TermQueryTranslatorTests.java new file mode 100644 index 0000000000000..938dc1cc154f1 --- /dev/null +++ b/sandbox/plugins/dsl-query-executor/src/test/java/org/opensearch/dsl/query/TermQueryTranslatorTests.java @@ -0,0 +1,66 @@ +/* + * 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.RexInputRef; +import org.apache.calcite.rex.RexLiteral; +import org.apache.calcite.rex.RexNode; +import org.apache.calcite.sql.SqlKind; +import org.opensearch.dsl.TestUtils; +import org.opensearch.dsl.converter.ConversionContext; +import org.opensearch.dsl.converter.ConversionException; +import org.opensearch.index.query.QueryBuilders; +import org.opensearch.index.query.TermQueryBuilder; +import org.opensearch.test.OpenSearchTestCase; + +public class TermQueryTranslatorTests extends OpenSearchTestCase { + + private final TermQueryTranslator translator = new TermQueryTranslator(); + private final ConversionContext ctx = TestUtils.createContext(); + + public void testConvertsTermQueryToEquals() throws ConversionException { + RexNode result = translator.convert(QueryBuilders.termQuery("name", "laptop"), ctx); + + assertTrue(result instanceof RexCall); + RexCall call = (RexCall) result; + assertEquals(SqlKind.EQUALS, call.getKind()); + assertEquals(2, call.getOperands().size()); + assertTrue(call.getOperands().get(0) instanceof RexInputRef); +// assertTrue(call.getOperands().get(1) instanceof RexLiteral); +// assertEquals("laptop", RexLiteral.stringValue(call.getOperands().get(1))); + } + + public void testResolvesCorrectFieldIndex() throws ConversionException { + RexNode result = translator.convert(QueryBuilders.termQuery("brand", "brandX"), ctx); + + RexCall call = (RexCall) result; + RexInputRef fieldRef = (RexInputRef) call.getOperands().get(0); + // brand is the 3rd field (index 2) in TestUtils schema: name, price, brand, rating + assertEquals(2, fieldRef.getIndex()); + } + + public void testIntegerValue() throws ConversionException { + RexNode result = translator.convert(QueryBuilders.termQuery("price", 1200), ctx); + + RexCall call = (RexCall) result; + assertEquals(SqlKind.EQUALS, call.getKind()); + // price is the 2nd field (index 1) + assertEquals(1, ((RexInputRef) call.getOperands().get(0)).getIndex()); + } + + public void testThrowsForUnknownField() { + expectThrows(ConversionException.class, + () -> translator.convert(QueryBuilders.termQuery("nonexistent", "value"), ctx)); + } + + public void testReportsCorrectQueryType() { + assertEquals(TermQueryBuilder.class, translator.getQueryType()); + } +} diff --git a/sandbox/plugins/dsl-query-executor/src/test/java/org/opensearch/dsl/result/ExecutionResultTests.java b/sandbox/plugins/dsl-query-executor/src/test/java/org/opensearch/dsl/result/ExecutionResultTests.java new file mode 100644 index 0000000000000..79a19d7dd8609 --- /dev/null +++ b/sandbox/plugins/dsl-query-executor/src/test/java/org/opensearch/dsl/result/ExecutionResultTests.java @@ -0,0 +1,35 @@ +/* + * 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.result; + +import org.opensearch.dsl.TestUtils; +import org.opensearch.dsl.executor.QueryPlans; +import org.opensearch.test.OpenSearchTestCase; + +import java.util.List; + +public class ExecutionResultTests extends OpenSearchTestCase { + + public void testExecutionResultCarriesPlanAndRows() { + QueryPlans.QueryPlan plan = new QueryPlans.QueryPlan(QueryPlans.Type.HITS, TestUtils.createTestRelNode()); + List rows = List.of(new Object[]{"laptop", 1200, "brandX", 4.5}); + ExecutionResult result = new ExecutionResult(plan, rows); + + assertSame(plan, result.getPlan()); + assertSame(rows, result.getRows()); + assertEquals(QueryPlans.Type.HITS, result.getType()); + assertEquals(List.of("name", "price", "brand", "rating"), result.getFieldNames()); + } + + public void testRejectsNullArguments() { + QueryPlans.QueryPlan plan = new QueryPlans.QueryPlan(QueryPlans.Type.HITS, TestUtils.createTestRelNode()); + expectThrows(NullPointerException.class, () -> new ExecutionResult(null, List.of())); + expectThrows(NullPointerException.class, () -> new ExecutionResult(plan, null)); + } +} diff --git a/sandbox/plugins/dsl-query-executor/src/test/java/org/opensearch/dsl/result/SearchResponseBuilderTests.java b/sandbox/plugins/dsl-query-executor/src/test/java/org/opensearch/dsl/result/SearchResponseBuilderTests.java new file mode 100644 index 0000000000000..869bd22504c39 --- /dev/null +++ b/sandbox/plugins/dsl-query-executor/src/test/java/org/opensearch/dsl/result/SearchResponseBuilderTests.java @@ -0,0 +1,26 @@ +/* + * 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.result; + +import org.opensearch.action.search.SearchResponse; +import org.opensearch.test.OpenSearchTestCase; + +import java.util.List; + +public class SearchResponseBuilderTests extends OpenSearchTestCase { + + public void testBuildReturnsEmptyResponse() { + SearchResponse response = SearchResponseBuilder.build(List.of(), 42L); + + assertNotNull(response); + assertEquals(200, response.status().getStatus()); + assertEquals(0, response.getHits().getHits().length); + assertEquals(42L, response.getTook().millis()); + } +}