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 index 8964835e36017..683d84137982f 100644 --- 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 @@ -12,12 +12,16 @@ import org.opensearch.action.support.ActionFilter; import org.opensearch.cluster.metadata.IndexNameExpressionResolver; import org.opensearch.cluster.service.ClusterService; +import org.opensearch.common.settings.Setting; 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.dsl.aggregation.AggregationRegistryFactory; +import org.opensearch.dsl.query.QueryRegistryFactory; +import org.opensearch.dsl.router.DslCalciteGrammar; import org.opensearch.env.Environment; import org.opensearch.env.NodeEnvironment; import org.opensearch.plugins.ActionPlugin; @@ -59,7 +63,8 @@ public Collection createComponents( IndexNameExpressionResolver indexNameExpressionResolver, Supplier repositoriesServiceSupplier ) { - this.searchActionFilter = new SearchActionFilter((NodeClient) client); + DslCalciteGrammar grammar = new DslCalciteGrammar(QueryRegistryFactory.create(), AggregationRegistryFactory.create()); + this.searchActionFilter = new SearchActionFilter((NodeClient) client, clusterService, grammar); return Collections.emptyList(); } @@ -72,4 +77,9 @@ public Collection createComponents( public List getActionFilters() { return searchActionFilter != null ? List.of(searchActionFilter) : List.of(); } + + @Override + public List> getSettings() { + return List.of(DslQueryExecutorSettings.CALCITE_ENABLED); + } } diff --git a/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/DslQueryExecutorSettings.java b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/DslQueryExecutorSettings.java new file mode 100644 index 0000000000000..783cad324f04a --- /dev/null +++ b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/DslQueryExecutorSettings.java @@ -0,0 +1,37 @@ +/* + * 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.common.settings.Setting; + +/** + * Cluster-scoped settings owned by the DSL query executor plugin. Registered via + * {@link DslQueryExecutorPlugin#getSettings()}. + */ +public final class DslQueryExecutorSettings { + + /** + * Master switch for Calcite-path routing. Defaults to {@code true} — the plugin routes + * {@code _search} through the grammar, with codec fallback on grammar rejection or + * conversion failure. Set to {@code false} to force every request through the codec + * path unchanged (operational escape hatch: mitigations, benchmarking, incident + * response). + * + *

Node-scoped + dynamic → cluster-wide, updatable via {@code PUT _cluster/settings} + * (use {@code persistent} to survive a full cluster restart). + */ + public static final Setting CALCITE_ENABLED = Setting.boolSetting( + "dsl.query_executor.calcite.enabled", + true, + Setting.Property.NodeScope, + Setting.Property.Dynamic + ); + + private DslQueryExecutorSettings() {} +} 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 index e6706b1afaf17..9c57634396ab3 100644 --- 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 @@ -8,6 +8,8 @@ package org.opensearch.dsl.action; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; import org.opensearch.action.ActionRequest; import org.opensearch.action.search.SearchAction; import org.opensearch.action.search.SearchRequest; @@ -15,29 +17,66 @@ import org.opensearch.action.support.ActionFilter; import org.opensearch.action.support.ActionFilterChain; import org.opensearch.action.support.ActionRequestMetadata; +import org.opensearch.cluster.service.ClusterService; import org.opensearch.core.action.ActionListener; import org.opensearch.core.action.ActionResponse; +import org.opensearch.dsl.DslQueryExecutorSettings; +import org.opensearch.dsl.converter.ConversionException; +import org.opensearch.dsl.router.DslCalciteGrammar; +import org.opensearch.dsl.router.RouteDecision; import org.opensearch.tasks.Task; import org.opensearch.transport.client.node.NodeClient; +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.Set; + /** - * Intercepts all {@code _search} requests and dispatches them to {@link DslExecuteAction} - * for execution through the Calcite pipeline. Non-search actions pass through unchanged. + * Intercepts {@code _search} requests and, when Calcite routing is enabled, decides between + * the Calcite path and the codec path. + * + *

Layer 1: {@link DslQueryExecutorSettings#CALCITE_ENABLED} — defaults to {@code true}; + * setting it to {@code false} forces every request through the codec path unchanged + * (operational escape hatch). + * + *

Layer 2 (only when the setting is on): + *

    + *
  • {@link DslCalciteGrammar#validate} classifies the request. Grammar-rejected requests + * fall back to the codec path.
  • + *
  • Grammar-accepted requests are dispatched to {@link DslExecuteAction}. A + * {@link ConversionException} during execution triggers codec fallback (schema-side + * checks the grammar can't run may still fail at conversion time). Non-conversion + * errors surface to the caller unchanged.
  • + *
*/ public class SearchActionFilter implements ActionFilter { + private static final Logger logger = LogManager.getLogger(SearchActionFilter.class); + /** Runs after the Security plugin's authorization filter (order 0). */ static final int FILTER_ORDER = 1; private final NodeClient client; + private final DslCalciteGrammar grammar; + /** + * Kept in sync with {@link DslQueryExecutorSettings#CALCITE_ENABLED} via a + * cluster-settings update consumer, so a {@code PUT _cluster/settings} change propagates + * without any per-request settings lookup. {@code volatile} because updates land on the + * cluster-state-applier thread while reads happen on transport/search threads. + */ + private volatile boolean calciteEnabled; /** - * Creates a filter that dispatches intercepted searches via the given client. - * * @param client node client for dispatching to {@link DslExecuteAction} + * @param clusterService source of the dynamic {@code CALCITE_ENABLED} setting value + * @param grammar route-decision oracle */ - public SearchActionFilter(NodeClient client) { + public SearchActionFilter(NodeClient client, ClusterService clusterService, DslCalciteGrammar grammar) { this.client = client; + this.grammar = grammar; + this.calciteEnabled = DslQueryExecutorSettings.CALCITE_ENABLED.get(clusterService.getSettings()); + clusterService.getClusterSettings() + .addSettingsUpdateConsumer(DslQueryExecutorSettings.CALCITE_ENABLED, v -> this.calciteEnabled = v); } @Override @@ -56,12 +95,47 @@ public void app ActionFilterChain chain ) { // TODO: add support for other search-related APIs (_msearch, _count, _search_shards, etc.). - // Consider two categories: APIs that execute search vs APIs that only explain/validate. - if (SearchAction.NAME.equals(action)) { - SearchRequest searchRequest = (SearchRequest) request; - client.execute(DslExecuteAction.INSTANCE, searchRequest, (ActionListener) listener); - } else { + if (!calciteEnabled || !SearchAction.NAME.equals(action)) { + chain.proceed(task, action, request, listener); + return; + } + + SearchRequest searchRequest = (SearchRequest) request; + RouteDecision decision = grammar.validate(searchRequest.source()); + + if (!decision.supported()) { + logger.debug("Grammar rejected _search, falling back to codec: {}", decision.unsupportedFeatures()); chain.proceed(task, action, request, listener); + return; + } + + ActionListener calciteListener = ActionListener.wrap( + (SearchResponse resp) -> ((ActionListener) listener).onResponse(resp), + error -> { + if (isFallbackable(error)) { + logger.debug("Calcite path threw ConversionException, falling back to codec", error); + chain.proceed(task, action, request, listener); + } else { + listener.onFailure(error); + } + } + ); + client.execute(DslExecuteAction.INSTANCE, searchRequest, calciteListener); + } + + /** + * Only fall back on failures that mean "Calcite can't handle this request" — grammar + * gaps or schema mismatches the grammar can't see. Runtime engine errors, timeouts, + * and other operational failures must surface to the caller so they can be diagnosed. + * + *

Uses an identity-based seen set to defend against pathological self-referential + * or cyclic exception chains (e.g. legacy code that calls {@code initCause(this)}). + */ + private static boolean isFallbackable(Throwable e) { + Set seen = Collections.newSetFromMap(new IdentityHashMap<>()); + for (Throwable t = e; t != null && seen.add(t); t = t.getCause()) { + if (t instanceof ConversionException) return true; } + return false; } } 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 index cf648a3d85b64..0cf3dae7df6da 100644 --- 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 @@ -36,6 +36,17 @@ public void register(AggregationTranslator translator) { translators.put(translator.getAggregationType(), translator); } + /** + * Returns {@code true} when a translator is registered for the given aggregation type. Used + * by routing (grammar) to decide whether a request can be sent to the Calcite path before + * any conversion work is attempted. + * + * @param type the concrete {@link AggregationBuilder} class + */ + public boolean hasTranslator(Class type) { + return translators.containsKey(type); + } + /** * Returns the translator for the given class, or null. * Caller checks {@code instanceof MetricTranslator} or {@code instanceof BucketTranslator}. 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 index 9297f6ca5cefd..471fbf921595b 100644 --- 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 @@ -15,14 +15,19 @@ import org.opensearch.dsl.aggregation.metric.SumMetricTranslator; /** - * Creates an {@link AggregationRegistry} populated with all supported translators. + * Returns the process-wide {@link AggregationRegistry} populated with all supported + * metric and bucket translators. Registrations are effectively immutable after class + * init, and the registry is safe to share across threads (concurrent reads, no writes + * at steady state). */ public class AggregationRegistryFactory { + /** Built once at class init and cached forever. */ + private static final AggregationRegistry INSTANCE = build(); + private AggregationRegistryFactory() {} - /** Creates a registry with all supported metric and bucket translators. */ - public static AggregationRegistry create() { + private static AggregationRegistry build() { AggregationRegistry registry = new AggregationRegistry(); registry.register(new AvgMetricTranslator()); registry.register(new SumMetricTranslator()); @@ -32,4 +37,9 @@ public static AggregationRegistry create() { // TODO: add other aggregation translators return registry; } + + /** Returns the shared registry. All callers see the same instance. */ + public static AggregationRegistry create() { + return INSTANCE; + } } 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 index 9fbde183793d6..745f082cf4f5d 100644 --- 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 @@ -37,6 +37,17 @@ public void register(QueryTranslator translator) { translators.put(translator.getQueryType(), translator); } + /** + * Returns {@code true} when a translator is registered for the given query type. Used by + * routing (grammar) to decide whether a request can be sent to the Calcite path before any + * conversion work is attempted. + * + * @param type the concrete {@link QueryBuilder} class + */ + public boolean hasTranslator(Class type) { + return translators.containsKey(type); + } + /** * Converts a query using the registered translator for its type. * If no translator is registered, wraps the query in an {@link UnresolvedQueryCall} diff --git a/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/query/QueryRegistryFactory.java b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/query/QueryRegistryFactory.java index f0bc550d59782..ad3294d553684 100644 --- a/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/query/QueryRegistryFactory.java +++ b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/query/QueryRegistryFactory.java @@ -9,14 +9,21 @@ package org.opensearch.dsl.query; /** - * Creates a {@link QueryRegistry} populated with all supported query translators. + * Returns the process-wide {@link QueryRegistry} populated with all supported query + * translators. Registrations are effectively immutable after class init, and the registry + * is safe to share across threads (concurrent reads, no writes at steady state). */ public class QueryRegistryFactory { + /** + * Built once at class init. The registry is populated in a private helper (not inline + * so we can keep the {@code register(...)} calls readable) and cached forever. + */ + private static final QueryRegistry INSTANCE = build(); + private QueryRegistryFactory() {} - /** Creates a registry with all supported query translators. */ - public static QueryRegistry create() { + private static QueryRegistry build() { QueryRegistry registry = new QueryRegistry(); registry.register(new TermQueryTranslator()); registry.register(new TermsQueryTranslator()); @@ -25,4 +32,9 @@ public static QueryRegistry create() { // TODO: add other query translators return registry; } + + /** Returns the shared registry. All callers see the same instance. */ + public static QueryRegistry create() { + return INSTANCE; + } } diff --git a/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/router/DslCalciteGrammar.java b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/router/DslCalciteGrammar.java new file mode 100644 index 0000000000000..500008a9a6fdd --- /dev/null +++ b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/router/DslCalciteGrammar.java @@ -0,0 +1,279 @@ +/* + * 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.router; + +import org.opensearch.dsl.aggregation.AggregationRegistry; +import org.opensearch.dsl.query.QueryRegistry; +import org.opensearch.index.query.AbstractQueryBuilder; +import org.opensearch.index.query.BoolQueryBuilder; +import org.opensearch.index.query.ConstantScoreQueryBuilder; +import org.opensearch.index.query.ExistsQueryBuilder; +import org.opensearch.index.query.PrefixQueryBuilder; +import org.opensearch.index.query.WildcardQueryBuilder; +import org.opensearch.index.query.QueryBuilder; +import org.opensearch.index.query.RangeQueryBuilder; +import org.opensearch.index.query.TermsQueryBuilder; +import org.opensearch.search.aggregations.AggregationBuilder; +import org.opensearch.search.aggregations.AggregatorFactories; +import org.opensearch.search.aggregations.PipelineAggregationBuilder; +import org.opensearch.search.builder.SearchSourceBuilder; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.stream.Stream; + +/** + * Decides whether a {@link SearchSourceBuilder} can be handled by the Calcite path. + * + *

Strategy: registry-lookup is the safe list. Any query/aggregation whose class has a + * translator registered is accepted structurally; per-parameter restrictions are layered on + * top only where the translator has known request-shape limitations. Compound queries + * ({@code bool}, {@code constant_score}) are transparent — recursed into, never looked up. + * + *

Reject reasons are returned as short reason codes (e.g. {@code "query:function_score"}, + * {@code "range.relation:DISJOINT"}, {@code "pipeline_agg:cumulative_sum"}) for observability + * without leaking user data. + * + *

v1 scope: + *

    + *
  • Query walker with per-parameter checks mirroring each registered query translator.
  • + *
  • Aggregation walker with registry check + pipeline-agg rejection; per-parameter checks + * per aggregation type are TODO and tracked inside {@code visitAggregation}.
  • + *
  • Top-level checks (size, sort, highlight, post_filter, etc.) are TODO — the response + * builder's hits are still stubbed, so top-level gating will be added when + * {@code buildHits} lands.
  • + *
+ */ +public class DslCalciteGrammar { + + private final QueryRegistry queryRegistry; + private final AggregationRegistry aggRegistry; + + /** + * @param queryRegistry the registry consulted for query-leaf safe list + * @param aggRegistry the registry consulted for aggregation safe list + */ + public DslCalciteGrammar(QueryRegistry queryRegistry, AggregationRegistry aggRegistry) { + this.queryRegistry = queryRegistry; + this.aggRegistry = aggRegistry; + } + + /** + * Validates a search source, returning a routing decision. Short-circuits at the first + * failing section: top-level issues skip the query walk, query issues skip the aggregation + * walk. + * + * @param source the request body; a {@code null} source is rejected up front + * ({@link org.opensearch.dsl.converter.SearchSourceConverter} would NPE) + */ + public RouteDecision validate(SearchSourceBuilder source) { + if (source == null) { + // SearchSourceConverter dereferences source.size()/source.aggregations() with + // no null guard — a null source would NPE the Calcite path. Semantically the + // request is a match_all, but that has to be expressed with an actual body. + return RouteDecision.rejected(List.of("source:null")); + } + + List issues = new ArrayList<>(); + + if (source.query() != null && !visitQuery(source.query(), issues)) { + return RouteDecision.rejected(issues); + } + + if (source.aggregations() != null && !visitAggregationTree(source.aggregations(), issues)) { + return RouteDecision.rejected(issues); + } + + return RouteDecision.accepted(); + } + + private boolean visitQuery(QueryBuilder q, List issues) { + // Compound queries are transparent to the registry — recurse into children. + switch (q) { + case BoolQueryBuilder b -> { + return visitBool(b, issues); + } + case ConstantScoreQueryBuilder csq -> { + return visitQuery(csq.innerQuery(), issues); + } + default -> { + } + } + + if (!queryRegistry.hasTranslator(q.getClass())) { + return reject("query:" + q.getName(), issues); + } + + // Per-parameter restrictions for registered types. Types not listed have + // translators that accept anything the class carries — the default arm passes + // them through structurally. Examples: + // - TermQueryTranslator: reads only fieldName/value, raises no rejects. + // - MatchAllQueryTranslator: reads nothing, always returns TRUE. Note that a + // null source.query() is treated by the converter as an implicit match_all + // (no filter) and is also accepted — but a null source itself is rejected + // up front (see validate()). + return switch (q) { + case RangeQueryBuilder r -> visitRangeQuery(r, issues); + case TermsQueryBuilder t -> visitTermsQuery(t, issues); + case ExistsQueryBuilder e -> visitExistsQuery(e, issues); + case PrefixQueryBuilder p -> visitPrefixQuery(p, issues); + case WildcardQueryBuilder w -> visitWildcardQuery(w, issues); + default -> true; + }; + } + + /** + * Per-parameter checks for {@code wildcard} query, mirroring + * {@code WildcardQueryTranslator}'s rejects. Same shape as {@code prefix}: + * {@code case_insensitive} is consumed, {@code boost}/{@code rewrite} rejected. + */ + private boolean visitWildcardQuery(WildcardQueryBuilder w, List issues) { + if (w.boost() != AbstractQueryBuilder.DEFAULT_BOOST) { + return reject(WildcardQueryBuilder.NAME + ".boost", issues); + } + if (w.rewrite() != null) { + return reject(WildcardQueryBuilder.NAME + ".rewrite", issues); + } + return true; + } + + /** + * Per-parameter checks for {@code prefix} query, mirroring {@code PrefixQueryTranslator}'s + * rejects. {@code case_insensitive} is consumed by the translator (folds to LOWER) and + * intentionally not rejected here. + */ + private boolean visitPrefixQuery(PrefixQueryBuilder p, List issues) { + if (p.boost() != AbstractQueryBuilder.DEFAULT_BOOST) { + return reject(PrefixQueryBuilder.NAME + ".boost", issues); + } + if (p.rewrite() != null) { + return reject(PrefixQueryBuilder.NAME + ".rewrite", issues); + } + return true; + } + + /** + * Per-parameter checks for {@code exists} query, mirroring {@code ExistsQueryTranslator}'s + * rejects. Only {@code boost} is checked — the translator silently ignores other params + * (including {@code _name}), matching the translator literally. + */ + private boolean visitExistsQuery(ExistsQueryBuilder e, List issues) { + if (e.boost() != AbstractQueryBuilder.DEFAULT_BOOST) { + return reject(ExistsQueryBuilder.NAME + ".boost", issues); + } + return true; + } + + /** + * Recurses into every clause of a {@code bool} query. {@code allMatch} short-circuits on + * the first failing child so the reject reason reflects the exact node that broke. + */ + private boolean visitBool(BoolQueryBuilder b, List issues) { + return Stream.of(b.must(), b.filter(), b.should(), b.mustNot()) + .flatMap(List::stream) + .allMatch(inner -> visitQuery(inner, issues)); + } + + /** + * Per-parameter checks for {@code terms} query, mirroring {@code TermsQueryTranslator}'s + * rejects. Field-existence and value-type-compatibility stay with the translator. + */ + private boolean visitTermsQuery(TermsQueryBuilder t, List issues) { + if (t.termsLookup() != null) { + return reject(TermsQueryBuilder.NAME + ".terms_lookup", issues); + } + if (t.boost() != AbstractQueryBuilder.DEFAULT_BOOST) { + return reject(TermsQueryBuilder.NAME + ".boost", issues); + } + if (t.queryName() != null) { + return reject(TermsQueryBuilder.NAME + ".name", issues); + } + if (t.valueType() != TermsQueryBuilder.ValueType.DEFAULT) { + return reject(TermsQueryBuilder.NAME + ".value_type:" + t.valueType(), issues); + } + if (t.values() == null || t.values().isEmpty()) { + return reject(TermsQueryBuilder.NAME + ".no_values", issues); + } + return true; + } + + /** + * Per-parameter checks for {@code range} query, mirroring {@code RangeQueryTranslator}'s + * rejects. Field-existence, binary-field guards, and date_nanos-precision checks stay + * with the translator (schema context is not available here). + * + *

The translator also rejects {@code relation=DISJOINT}, but that path is + * unreachable: {@link RangeQueryBuilder#relation(String)} rejects {@code DISJOINT} at + * construction time, so no such request can ever reach the grammar. + */ + private boolean visitRangeQuery(RangeQueryBuilder r, List issues) { + if (r.boost() != AbstractQueryBuilder.DEFAULT_BOOST) { + return reject(RangeQueryBuilder.NAME + ".boost", issues); + } + if (r.queryName() != null) { + return reject(RangeQueryBuilder.NAME + ".name", issues); + } + if (r.from() == null && r.to() == null) { + return reject(RangeQueryBuilder.NAME + ".no_bounds", issues); + } + return true; + } + + /** + * Entry point for the aggregation section: rejects top-level pipeline aggregations, then + * walks each top-level regular aggregation. + */ + private boolean visitAggregationTree(AggregatorFactories.Builder aggs, List issues) { + return visitPipelineAggregations(aggs.getPipelineAggregatorFactories(), issues) + && visitAggregations(aggs.getAggregatorFactories(), issues); + } + + /** Collection-level driver: short-circuits at the first failing aggregation. */ + private boolean visitAggregations(Collection aggs, List issues) { + return aggs == null || aggs.stream().allMatch(agg -> visitAggregation(agg, issues)); + } + + /** + * Pipeline aggregations have no Calcite equivalent — any presence is a hard reject. + * Called from both the top-level tree walk and the per-node recursion, since pipelines + * can appear as siblings of any level's regular aggregations. + * + *

TODO: when a {@code PipelineAggregationRegistry} (or equivalent) is introduced, + * replace the blanket reject with a registry check + per-type per-parameter switch, + * mirroring the query and normal-aggregation paths. + */ + private boolean visitPipelineAggregations(Collection pipelines, List issues) { + if (pipelines == null || pipelines.isEmpty()) { + return true; + } + + return reject("pipeline_agg:" + pipelines.iterator().next().getName(), issues); + } + + private boolean visitAggregation(AggregationBuilder agg, List issues) { + if (!aggRegistry.hasTranslator(agg.getClass())) { + return reject("agg:" + agg.getType(), issues); + } + + // TODO: per-parameter checks per aggregation type. To be filled in as each + // aggregation translator (avg/sum/min/max/value_count/stats/extended_stats/terms/...) + // is reviewed for the exact params it consumes vs silently ignores. Same pattern as + // the query-side switch above — mirror the translator's rejects here to route to + // codec early instead of failing at conversion time. + + return visitPipelineAggregations(agg.getPipelineAggregations(), issues) + && visitAggregations(agg.getSubAggregations(), issues); + } + + private static boolean reject(String reason, List issues) { + issues.add(reason); + return false; + } +} diff --git a/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/router/RouteDecision.java b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/router/RouteDecision.java new file mode 100644 index 0000000000000..753393a768081 --- /dev/null +++ b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/router/RouteDecision.java @@ -0,0 +1,53 @@ +/* + * 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.router; + +import java.util.Collections; +import java.util.List; + +/** + * Result of a {@link DslCalciteGrammar#validate} pass over a {@code SearchSourceBuilder}. + * + *

A request is {@link #supported()} when the grammar accepts every node in the DSL tree. + * Otherwise {@link #unsupportedFeatures()} enumerates the reason codes that caused rejection + * (e.g. {@code "script"}, {@code "range.time_zone"}). + * + *

Callers use this record to route the request: + *

{@code
+ * RouteDecision decision = grammar.validate(source);
+ * if (decision.supported()) {
+ *     // Calcite path
+ * } else {
+ *     // codec path; log decision.unsupportedFeatures() for observability
+ * }
+ * }
+ */ +public record RouteDecision(boolean supported, List unsupportedFeatures) { + + /** Cached instance for the common "everything is fine" result. */ + private static final RouteDecision SUPPORTED = new RouteDecision(true, Collections.emptyList()); + + /** + * Returns the singleton supported decision. Named {@code accepted} rather than + * {@code supported} to avoid colliding with the record's own {@code supported()} + * accessor (Java rejects a static factory with the same signature as an accessor). + */ + public static RouteDecision accepted() { + return SUPPORTED; + } + + /** + * Constructs a rejection with the given reason codes. + * + * @param features the reason codes accumulated during the walk + */ + public static RouteDecision rejected(List features) { + return new RouteDecision(false, List.copyOf(features)); + } +} diff --git a/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/router/package-info.java b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/router/package-info.java new file mode 100644 index 0000000000000..b1b985963241a --- /dev/null +++ b/sandbox/plugins/dsl-query-executor/src/main/java/org/opensearch/dsl/router/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. + */ + +/** Routing gate that classifies incoming {@code _search} DSL between the Calcite path and the codec fallback. */ +package org.opensearch.dsl.router; 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 index 5ba54f9541bad..28a0bfc38c771 100644 --- 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 @@ -9,6 +9,9 @@ package org.opensearch.dsl; import org.opensearch.action.support.ActionFilter; +import org.opensearch.cluster.service.ClusterService; +import org.opensearch.common.settings.ClusterSettings; +import org.opensearch.common.settings.Settings; import org.opensearch.dsl.action.DslExecuteAction; import org.opensearch.dsl.action.SearchActionFilter; import org.opensearch.dsl.action.TransportDslExecuteAction; @@ -17,8 +20,10 @@ import org.opensearch.transport.client.node.NodeClient; import java.util.List; +import java.util.Set; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; public class DslQueryExecutorPluginTests extends OpenSearchTestCase { @@ -37,7 +42,16 @@ public void testGetActionFiltersEmptyBeforeCreateComponents() { } public void testGetActionFiltersAfterCreateComponents() { - plugin.createComponents(mock(NodeClient.class), null, null, null, null, null, null, null, null, null, null); + // SearchActionFilter subscribes to CALCITE_ENABLED via ClusterService — a real (but + // minimal) ClusterSettings is needed so addSettingsUpdateConsumer() has somewhere to + // register. Other createComponents params remain null: none of them are dereferenced. + Settings settings = Settings.EMPTY; + ClusterSettings clusterSettings = new ClusterSettings(settings, Set.of(DslQueryExecutorSettings.CALCITE_ENABLED)); + ClusterService clusterService = mock(ClusterService.class); + when(clusterService.getSettings()).thenReturn(settings); + when(clusterService.getClusterSettings()).thenReturn(clusterSettings); + + plugin.createComponents(mock(NodeClient.class), clusterService, null, null, null, null, null, null, null, null, null); List filters = plugin.getActionFilters(); assertEquals(1, filters.size()); 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 index 1df9f6a03cbd8..ddb9a40f820a5 100644 --- 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 @@ -8,24 +8,38 @@ package org.opensearch.dsl.action; +import org.mockito.ArgumentCaptor; 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.search.SearchResponse; import org.opensearch.action.support.ActionFilterChain; import org.opensearch.action.support.ActionRequestMetadata; +import org.opensearch.cluster.service.ClusterService; +import org.opensearch.common.settings.ClusterSettings; +import org.opensearch.common.settings.Settings; import org.opensearch.core.action.ActionListener; import org.opensearch.core.action.ActionResponse; +import org.opensearch.dsl.DslQueryExecutorSettings; +import org.opensearch.dsl.converter.ConversionException; +import org.opensearch.dsl.router.DslCalciteGrammar; +import org.opensearch.dsl.router.RouteDecision; +import org.opensearch.search.builder.SearchSourceBuilder; 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 java.util.List; +import java.util.Set; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; @SuppressWarnings("unchecked") public class SearchActionFilterTests extends OpenSearchTestCase { @@ -35,29 +49,147 @@ public class SearchActionFilterTests extends OpenSearchTestCase { private final ActionListener listener = mock(ActionListener.class); private final ActionFilterChain chain = mock(ActionFilterChain.class); private final ActionRequestMetadata metadata = mock(ActionRequestMetadata.class); - private final SearchActionFilter filter = new SearchActionFilter(client); + private final DslCalciteGrammar grammar = mock(DslCalciteGrammar.class); + + private SearchActionFilter buildFilter(boolean calciteEnabled) { + Settings settings = Settings.builder() + .put(DslQueryExecutorSettings.CALCITE_ENABLED.getKey(), calciteEnabled) + .build(); + ClusterSettings clusterSettings = new ClusterSettings(settings, Set.of(DslQueryExecutorSettings.CALCITE_ENABLED)); + ClusterService clusterService = mock(ClusterService.class); + when(clusterService.getSettings()).thenReturn(settings); + when(clusterService.getClusterSettings()).thenReturn(clusterSettings); + return new SearchActionFilter(client, clusterService, grammar); + } public void testOrderRunsAfterSecurityFilter() { + SearchActionFilter filter = buildFilter(true); assertEquals(SearchActionFilter.FILTER_ORDER, filter.order()); } - public void testPassesThroughNonSearchAction() { + // ---- Layer 1: master switch ---- + + public void testCalciteDisabledSendsSearchToCodec() { + SearchActionFilter filter = buildFilter(false); + SearchRequest request = new SearchRequest("test-index"); + + filter.apply(task, SearchAction.NAME, request, metadata, listener, chain); + + verify(chain).proceed(task, SearchAction.NAME, request, listener); + verify(client, never()).execute(any(), any(), any()); + verify(grammar, never()).validate(any()); + } + + public void testCalciteEnabledButNonSearchActionPassesThrough() { + SearchActionFilter filter = buildFilter(true); BulkRequest request = new BulkRequest(); filter.apply(task, BulkAction.NAME, request, metadata, listener, chain); verify(chain).proceed(task, BulkAction.NAME, request, listener); verify(client, never()).execute(any(), any(), any()); + verify(grammar, never()).validate(any()); } - // TODO: add tests to verify reroute only happens when the target index has the setting enabled + // ---- Layer 2: grammar decision ---- - public void testReroutesSearchAction() { - SearchRequest request = new SearchRequest("test-index"); + public void testGrammarRejectFallsBackToCodec() { + SearchActionFilter filter = buildFilter(true); + SearchRequest request = new SearchRequest("test-index").source(new SearchSourceBuilder()); + when(grammar.validate(any())).thenReturn(RouteDecision.rejected(List.of("query:match"))); + + filter.apply(task, SearchAction.NAME, request, metadata, listener, chain); + + verify(grammar).validate(request.source()); + verify(chain).proceed(task, SearchAction.NAME, request, listener); + verify(client, never()).execute(any(), any(), any()); + } + + public void testGrammarAcceptDispatchesToCalcite() { + SearchActionFilter filter = buildFilter(true); + SearchRequest request = new SearchRequest("test-index").source(new SearchSourceBuilder()); + when(grammar.validate(any())).thenReturn(RouteDecision.accepted()); filter.apply(task, SearchAction.NAME, request, metadata, listener, chain); verify(client).execute(eq(DslExecuteAction.INSTANCE), eq(request), any()); verify(chain, never()).proceed(any(), any(), any(), any()); } + + // ---- Layer 2 fallback: ConversionException triggers codec ---- + + public void testConversionExceptionInCalciteFallsBackToCodec() { + SearchActionFilter filter = buildFilter(true); + SearchRequest request = new SearchRequest("test-index").source(new SearchSourceBuilder()); + when(grammar.validate(any())).thenReturn(RouteDecision.accepted()); + + filter.apply(task, SearchAction.NAME, request, metadata, listener, chain); + + // Grab the wrapped listener passed to client.execute and simulate a ConversionException. + ArgumentCaptor> captor = ArgumentCaptor.forClass(ActionListener.class); + verify(client).execute(eq(DslExecuteAction.INSTANCE), eq(request), captor.capture()); + + captor.getValue().onFailure(new ConversionException("field 'x' not in schema")); + + // Fallback = chain.proceed with the original listener. + verify(chain).proceed(task, SearchAction.NAME, request, listener); + verify(listener, never()).onFailure(any()); + } + + public void testWrappedConversionExceptionAlsoTriggersFallback() { + SearchActionFilter filter = buildFilter(true); + SearchRequest request = new SearchRequest("test-index").source(new SearchSourceBuilder()); + when(grammar.validate(any())).thenReturn(RouteDecision.accepted()); + + filter.apply(task, SearchAction.NAME, request, metadata, listener, chain); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(ActionListener.class); + verify(client).execute(eq(DslExecuteAction.INSTANCE), eq(request), captor.capture()); + + // Simulate an OS-side wrapper around the real cause. + Exception wrapped = new RuntimeException("engine failed", new ConversionException("nested cause")); + captor.getValue().onFailure(wrapped); + + verify(chain).proceed(task, SearchAction.NAME, request, listener); + } + + public void testNonConversionExceptionSurfacesToCaller() { + SearchActionFilter filter = buildFilter(true); + SearchRequest request = new SearchRequest("test-index").source(new SearchSourceBuilder()); + when(grammar.validate(any())).thenReturn(RouteDecision.accepted()); + + filter.apply(task, SearchAction.NAME, request, metadata, listener, chain); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(ActionListener.class); + verify(client).execute(eq(DslExecuteAction.INSTANCE), eq(request), captor.capture()); + + RuntimeException engineErr = new RuntimeException("engine timeout"); + captor.getValue().onFailure(engineErr); + + // No fallback for runtime errors — the caller sees the failure. + verify(listener).onFailure(engineErr); + verify(chain, never()).proceed(any(), any(), any(), any()); + } + + // ---- dynamic setting update ---- + + public void testDynamicDisableStopsInterception() { + Settings initial = Settings.builder().put(DslQueryExecutorSettings.CALCITE_ENABLED.getKey(), true).build(); + ClusterSettings clusterSettings = new ClusterSettings(initial, Set.of(DslQueryExecutorSettings.CALCITE_ENABLED)); + ClusterService clusterService = mock(ClusterService.class); + when(clusterService.getSettings()).thenReturn(initial); + when(clusterService.getClusterSettings()).thenReturn(clusterSettings); + SearchActionFilter filter = new SearchActionFilter(client, clusterService, grammar); + + // Push a dynamic update: calcite off. + Settings updated = Settings.builder().put(DslQueryExecutorSettings.CALCITE_ENABLED.getKey(), false).build(); + clusterSettings.applySettings(updated); + + SearchRequest request = new SearchRequest("test-index").source(new SearchSourceBuilder()); + filter.apply(task, SearchAction.NAME, request, metadata, listener, chain); + + verify(chain).proceed(task, SearchAction.NAME, request, listener); + verify(client, never()).execute(any(), any(), any()); + verify(grammar, never()).validate(any()); + } } diff --git a/sandbox/plugins/dsl-query-executor/src/test/java/org/opensearch/dsl/router/DslCalciteGrammarTests.java b/sandbox/plugins/dsl-query-executor/src/test/java/org/opensearch/dsl/router/DslCalciteGrammarTests.java new file mode 100644 index 0000000000000..daa8773c54889 --- /dev/null +++ b/sandbox/plugins/dsl-query-executor/src/test/java/org/opensearch/dsl/router/DslCalciteGrammarTests.java @@ -0,0 +1,328 @@ +/* + * 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.router; + +import org.mockito.Mockito; +import org.opensearch.dsl.aggregation.AggregationRegistry; +import org.opensearch.dsl.aggregation.AggregationRegistryFactory; +import org.opensearch.dsl.query.QueryRegistry; +import org.opensearch.dsl.query.QueryRegistryFactory; +import org.opensearch.index.query.PrefixQueryBuilder; +import org.opensearch.index.query.QueryBuilder; +import org.opensearch.index.query.QueryBuilders; +import org.opensearch.index.query.RangeQueryBuilder; +import org.opensearch.index.query.TermsQueryBuilder; +import org.opensearch.index.query.WildcardQueryBuilder; +import org.opensearch.search.aggregations.AggregationBuilders; +import org.opensearch.search.aggregations.PipelineAggregatorBuilders; +import org.opensearch.search.builder.SearchSourceBuilder; +import org.opensearch.test.OpenSearchTestCase; + +public class DslCalciteGrammarTests extends OpenSearchTestCase { + + private final AggregationRegistry aggRegistry = AggregationRegistryFactory.create(); + + /** Grammar with only the translators actually registered on this branch. */ + private final DslCalciteGrammar grammar = new DslCalciteGrammar(QueryRegistryFactory.create(), aggRegistry); + + /** + * Grammar with extra query types treated as if their translators were registered. + * Used to exercise per-parameter checks for range/prefix/wildcard whose translators + * live behind pending PRs (#22525, #22526). + */ + @SafeVarargs + private DslCalciteGrammar grammarWith(Class... registeredExtras) { + QueryRegistry spy = Mockito.spy(QueryRegistryFactory.create()); + for (Class c : registeredExtras) { + Mockito.doReturn(true).when(spy).hasTranslator(c); + } + return new DslCalciteGrammar(spy, aggRegistry); + } + + // ---- source-level ---- + + public void testNullSourceRejected() { + RouteDecision decision = grammar.validate(null); + assertFalse(decision.supported()); + assertEquals(1, decision.unsupportedFeatures().size()); + assertEquals("source:null", decision.unsupportedFeatures().get(0)); + } + + public void testEmptySourceSupported() { + assertTrue(grammar.validate(new SearchSourceBuilder()).supported()); + } + + // ---- query walker: registry gate ---- + + public void testRegisteredLeafQuerySupported() { + SearchSourceBuilder source = new SearchSourceBuilder().query(QueryBuilders.termQuery("name", "laptop")); + assertTrue(grammar.validate(source).supported()); + } + + public void testUnregisteredLeafQueryRejected() { + SearchSourceBuilder source = new SearchSourceBuilder().query(QueryBuilders.matchQuery("name", "laptop")); + RouteDecision decision = grammar.validate(source); + assertFalse(decision.supported()); + assertEquals("query:match", decision.unsupportedFeatures().get(0)); + } + + public void testBoolRecursesIntoRegisteredChildren() { + SearchSourceBuilder source = new SearchSourceBuilder().query( + QueryBuilders.boolQuery() + .must(QueryBuilders.termQuery("brand", "Acme")) + .filter(QueryBuilders.existsQuery("price")) + ); + assertTrue(grammar.validate(source).supported()); + } + + public void testBoolRejectsIfAnyChildRejected() { + SearchSourceBuilder source = new SearchSourceBuilder().query( + QueryBuilders.boolQuery() + .must(QueryBuilders.termQuery("brand", "Acme")) + .must(QueryBuilders.matchQuery("desc", "fast")) // unregistered + ); + RouteDecision decision = grammar.validate(source); + assertFalse(decision.supported()); + assertEquals("query:match", decision.unsupportedFeatures().get(0)); + } + + public void testConstantScoreRecurses() { + SearchSourceBuilder ok = new SearchSourceBuilder().query( + QueryBuilders.constantScoreQuery(QueryBuilders.termQuery("brand", "Acme")) + ); + assertTrue(grammar.validate(ok).supported()); + + SearchSourceBuilder bad = new SearchSourceBuilder().query( + QueryBuilders.constantScoreQuery(QueryBuilders.matchQuery("desc", "fast")) + ); + assertFalse(grammar.validate(bad).supported()); + } + + // ---- range per-param (uses a spy since translator isn't merged yet) ---- + + public void testRangeSupported() { + DslCalciteGrammar g = grammarWith(RangeQueryBuilder.class); + SearchSourceBuilder source = new SearchSourceBuilder().query(QueryBuilders.rangeQuery("price").gt(100).lt(500)); + assertTrue(g.validate(source).supported()); + } + + public void testRangeRejectsBoost() { + DslCalciteGrammar g = grammarWith(RangeQueryBuilder.class); + RangeQueryBuilder q = QueryBuilders.rangeQuery("price").gt(100).boost(2.0f); + RouteDecision decision = g.validate(new SearchSourceBuilder().query(q)); + assertFalse(decision.supported()); + assertEquals("range.boost", decision.unsupportedFeatures().get(0)); + } + + public void testRangeRejectsName() { + DslCalciteGrammar g = grammarWith(RangeQueryBuilder.class); + RangeQueryBuilder q = QueryBuilders.rangeQuery("price").gt(100).queryName("my_range"); + RouteDecision decision = g.validate(new SearchSourceBuilder().query(q)); + assertFalse(decision.supported()); + assertEquals("range.name", decision.unsupportedFeatures().get(0)); + } + + public void testRangeRejectsNoBounds() { + DslCalciteGrammar g = grammarWith(RangeQueryBuilder.class); + RangeQueryBuilder q = QueryBuilders.rangeQuery("price"); + RouteDecision decision = g.validate(new SearchSourceBuilder().query(q)); + assertFalse(decision.supported()); + assertEquals("range.no_bounds", decision.unsupportedFeatures().get(0)); + } + + /** Covers the {@code from != null, to == null} branch of the compound bounds check. */ + public void testRangeWithOnlyLowerBoundSupported() { + DslCalciteGrammar g = grammarWith(RangeQueryBuilder.class); + assertTrue(g.validate(new SearchSourceBuilder().query(QueryBuilders.rangeQuery("price").gt(100))).supported()); + } + + /** Covers the {@code from == null, to != null} branch of the compound bounds check. */ + public void testRangeWithOnlyUpperBoundSupported() { + DslCalciteGrammar g = grammarWith(RangeQueryBuilder.class); + assertTrue(g.validate(new SearchSourceBuilder().query(QueryBuilders.rangeQuery("price").lt(500))).supported()); + } + + // Note: DISJOINT is rejected by RangeQueryBuilder.relation() at construction time + // (see isRelationAllowed) — INTERSECTS/CONTAINS/WITHIN are the only reachable values, + // and all three are accepted by the translator. So the grammar has nothing to check + // for range.relation, and no test can produce a builder carrying DISJOINT. + + // ---- terms query per-param ---- + + public void testTermsQuerySupported() { + SearchSourceBuilder source = new SearchSourceBuilder().query(QueryBuilders.termsQuery("brand", "Acme", "Bravo")); + assertTrue(grammar.validate(source).supported()); + } + + public void testTermsRejectsBoost() { + TermsQueryBuilder q = QueryBuilders.termsQuery("brand", "Acme").boost(2.0f); + RouteDecision d = grammar.validate(new SearchSourceBuilder().query(q)); + assertFalse(d.supported()); + assertEquals("terms.boost", d.unsupportedFeatures().get(0)); + } + + public void testTermsRejectsName() { + TermsQueryBuilder q = QueryBuilders.termsQuery("brand", "Acme").queryName("labelled"); + RouteDecision d = grammar.validate(new SearchSourceBuilder().query(q)); + assertFalse(d.supported()); + assertEquals("terms.name", d.unsupportedFeatures().get(0)); + } + + public void testTermsRejectsEmptyValues() { + TermsQueryBuilder q = QueryBuilders.termsQuery("brand", new String[0]); + RouteDecision d = grammar.validate(new SearchSourceBuilder().query(q)); + assertFalse(d.supported()); + assertEquals("terms.no_values", d.unsupportedFeatures().get(0)); + } + + public void testTermsRejectsTermsLookup() { + TermsQueryBuilder q = new TermsQueryBuilder( + "brand", + new org.opensearch.indices.TermsLookup("lookup_idx", "1", "brands") + ); + RouteDecision d = grammar.validate(new SearchSourceBuilder().query(q)); + assertFalse(d.supported()); + assertEquals("terms.terms_lookup", d.unsupportedFeatures().get(0)); + } + + public void testTermsRejectsNonDefaultValueType() { + TermsQueryBuilder q = QueryBuilders.termsQuery("brand", "Acme").valueType(TermsQueryBuilder.ValueType.BITMAP); + RouteDecision d = grammar.validate(new SearchSourceBuilder().query(q)); + assertFalse(d.supported()); + assertTrue(d.unsupportedFeatures().get(0).startsWith("terms.value_type:")); + } + + // ---- exists per-param ---- + + public void testExistsSupported() { + SearchSourceBuilder source = new SearchSourceBuilder().query(QueryBuilders.existsQuery("price")); + assertTrue(grammar.validate(source).supported()); + } + + public void testExistsRejectsBoost() { + RouteDecision d = grammar.validate(new SearchSourceBuilder().query(QueryBuilders.existsQuery("price").boost(3.0f))); + assertFalse(d.supported()); + assertEquals("exists.boost", d.unsupportedFeatures().get(0)); + } + + // ---- prefix per-param (spy) ---- + + public void testPrefixSupported() { + DslCalciteGrammar g = grammarWith(PrefixQueryBuilder.class); + assertTrue(g.validate(new SearchSourceBuilder().query(QueryBuilders.prefixQuery("name", "lap"))).supported()); + } + + public void testPrefixCaseInsensitiveSupported() { + // Consumed by translator (folds to LOWER), not rejected. + DslCalciteGrammar g = grammarWith(PrefixQueryBuilder.class); + assertTrue( + g.validate(new SearchSourceBuilder().query(QueryBuilders.prefixQuery("name", "lap").caseInsensitive(true))).supported() + ); + } + + public void testPrefixRejectsBoost() { + DslCalciteGrammar g = grammarWith(PrefixQueryBuilder.class); + RouteDecision d = g.validate(new SearchSourceBuilder().query(QueryBuilders.prefixQuery("name", "lap").boost(2.0f))); + assertFalse(d.supported()); + assertEquals("prefix.boost", d.unsupportedFeatures().get(0)); + } + + public void testPrefixRejectsRewrite() { + DslCalciteGrammar g = grammarWith(PrefixQueryBuilder.class); + RouteDecision d = g.validate(new SearchSourceBuilder().query(QueryBuilders.prefixQuery("name", "lap").rewrite("constant_score"))); + assertFalse(d.supported()); + assertEquals("prefix.rewrite", d.unsupportedFeatures().get(0)); + } + + // ---- wildcard per-param (spy) ---- + + public void testWildcardSupported() { + DslCalciteGrammar g = grammarWith(WildcardQueryBuilder.class); + assertTrue(g.validate(new SearchSourceBuilder().query(QueryBuilders.wildcardQuery("name", "lap*"))).supported()); + } + + public void testWildcardRejectsBoost() { + DslCalciteGrammar g = grammarWith(WildcardQueryBuilder.class); + RouteDecision d = g.validate(new SearchSourceBuilder().query(QueryBuilders.wildcardQuery("name", "lap*").boost(2.0f))); + assertFalse(d.supported()); + assertEquals("wildcard.boost", d.unsupportedFeatures().get(0)); + } + + public void testWildcardRejectsRewrite() { + DslCalciteGrammar g = grammarWith(WildcardQueryBuilder.class); + RouteDecision d = g.validate( + new SearchSourceBuilder().query(QueryBuilders.wildcardQuery("name", "lap*").rewrite("constant_score")) + ); + assertFalse(d.supported()); + assertEquals("wildcard.rewrite", d.unsupportedFeatures().get(0)); + } + + // ---- aggregation walker ---- + + public void testRegisteredAggSupported() { + SearchSourceBuilder source = new SearchSourceBuilder().size(0) + .aggregation(AggregationBuilders.avg("avg_price").field("price")); + assertTrue(grammar.validate(source).supported()); + } + + public void testUnregisteredAggRejected() { + SearchSourceBuilder source = new SearchSourceBuilder().size(0) + .aggregation(AggregationBuilders.cardinality("card_brand").field("brand")); + RouteDecision d = grammar.validate(source); + assertFalse(d.supported()); + assertEquals("agg:cardinality", d.unsupportedFeatures().get(0)); + } + + public void testNestedSubAggWalked() { + SearchSourceBuilder source = new SearchSourceBuilder().size(0) + .aggregation( + AggregationBuilders.terms("by_brand").field("brand") + .subAggregation(AggregationBuilders.cardinality("card").field("sku")) // unregistered + ); + RouteDecision d = grammar.validate(source); + assertFalse(d.supported()); + assertEquals("agg:cardinality", d.unsupportedFeatures().get(0)); + } + + // ---- pipeline agg ---- + + public void testTopLevelPipelineAggRejected() { + SearchSourceBuilder source = new SearchSourceBuilder().size(0) + .aggregation(AggregationBuilders.terms("by_brand").field("brand")) + .aggregation(PipelineAggregatorBuilders.maxBucket("max_bucket", "by_brand>_count")); + RouteDecision d = grammar.validate(source); + assertFalse(d.supported()); + assertTrue(d.unsupportedFeatures().get(0).startsWith("pipeline_agg:")); + } + + public void testNestedPipelineAggRejected() { + SearchSourceBuilder source = new SearchSourceBuilder().size(0) + .aggregation( + AggregationBuilders.terms("by_brand").field("brand") + .subAggregation(AggregationBuilders.sum("sales").field("price")) + .subAggregation(PipelineAggregatorBuilders.cumulativeSum("cum", "sales")) + ); + RouteDecision d = grammar.validate(source); + assertFalse(d.supported()); + assertTrue(d.unsupportedFeatures().get(0).startsWith("pipeline_agg:")); + } + + // ---- short-circuit ---- + + public void testQueryFailureShortCircuitsBeforeAggWalk() { + // Aggs contain an unregistered agg too, but the query fails first — we should + // only see the query reason. + SearchSourceBuilder source = new SearchSourceBuilder().query(QueryBuilders.matchQuery("desc", "fast")) + .aggregation(AggregationBuilders.cardinality("card").field("sku")); + RouteDecision d = grammar.validate(source); + assertFalse(d.supported()); + assertEquals(1, d.unsupportedFeatures().size()); + assertEquals("query:match", d.unsupportedFeatures().get(0)); + } +}