This is an automated email from the ASF dual-hosted git repository.
yashmayya pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/pinot.git
The following commit(s) were added to refs/heads/master by this push:
new 49ce7be1798 Support IN_SUBQUERY in the multi-stage engine and run
IdSet subqueries first (#19804)
49ce7be1798 is described below
commit 49ce7be17983a16154fc639e45821256413e736d
Author: Yash Mayya <[email protected]>
AuthorDate: Thu Oct 15 12:00:45 2026 -0700
Support IN_SUBQUERY in the multi-stage engine and run IdSet subqueries
first (#19804)
---
.../requesthandler/IdSetSubqueryRewriter.java | 264 +++++++++++
.../MultiStageBrokerRequestHandler.java | 176 +++++++-
.../requesthandler/IdSetSubqueryRewriterTest.java | 309 +++++++++++++
.../MultiStageIdSetSubqueryTest.java | 491 +++++++++++++++++++++
.../response/broker/BrokerResponseNativeV2.java | 2 +-
.../pinot/common/utils/request/RequestUtils.java | 8 +-
.../broker/BrokerResponseNativeV2Test.java | 15 +
.../tests/BaseClusterIntegrationTestSet.java | 7 +-
.../tests/JmxMetricsIntegrationTest.java | 18 +-
.../tests/custom/IdSetIntegrationTest.java | 102 ++++-
.../pinot/calcite/sql/fun/PinotOperatorTable.java | 6 +
.../org/apache/pinot/query/QueryEnvironment.java | 14 +
.../apache/pinot/query/QueryCompilationTest.java | 34 ++
13 files changed, 1425 insertions(+), 21 deletions(-)
diff --git
a/pinot-broker/src/main/java/org/apache/pinot/broker/requesthandler/IdSetSubqueryRewriter.java
b/pinot-broker/src/main/java/org/apache/pinot/broker/requesthandler/IdSetSubqueryRewriter.java
new file mode 100644
index 00000000000..ea8980ae129
--- /dev/null
+++
b/pinot-broker/src/main/java/org/apache/pinot/broker/requesthandler/IdSetSubqueryRewriter.java
@@ -0,0 +1,264 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.pinot.broker.requesthandler;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import javax.annotation.Nullable;
+import org.apache.calcite.sql.SqlBasicCall;
+import org.apache.calcite.sql.SqlCall;
+import org.apache.calcite.sql.SqlCharStringLiteral;
+import org.apache.calcite.sql.SqlFunction;
+import org.apache.calcite.sql.SqlFunctionCategory;
+import org.apache.calcite.sql.SqlIdentifier;
+import org.apache.calcite.sql.SqlKind;
+import org.apache.calcite.sql.SqlLiteral;
+import org.apache.calcite.sql.SqlNode;
+import org.apache.calcite.sql.SqlOrderBy;
+import org.apache.calcite.sql.SqlUnresolvedFunction;
+import org.apache.calcite.sql.SqlWith;
+import org.apache.calcite.sql.SqlWithItem;
+import org.apache.calcite.sql.parser.SqlParserPos;
+import org.apache.calcite.sql.parser.SqlParserUtil;
+import org.apache.calcite.sql.util.SqlBasicVisitor;
+import org.apache.calcite.util.Litmus;
+import org.apache.pinot.common.function.FunctionRegistry;
+import org.apache.pinot.core.query.utils.idset.IdSets;
+import org.apache.pinot.spi.exception.QueryErrorCode;
+import org.apache.pinot.sql.parsers.CalciteSqlParser;
+
+
+/// Runs the IdSet subqueries of a multi-stage query before the query is
compiled, and puts the IdSets they return in
+/// their place, so that the planner and the servers only see `IN_ID_SET`
calls on literals:
+/// - `IN_SUBQUERY(expr, 'subquery')` becomes `IN_ID_SET(expr, 'idSet')`,
anywhere in the query.
+/// - `IN_ID_SET(expr, (subquery))` becomes `IN_ID_SET(expr, 'idSet')` when
the subquery validates on its own, i.e.
+/// when it is not correlated, and does not name a WITH item of the query.
Otherwise the subquery stays, and runs as
+/// part of the query.
+///
+/// A subquery that appears more than once runs once. To explain a query, no
subquery runs: the empty IdSet takes the
+/// place of each result.
+///
+/// The rewrite changes the parsed query in place, so it is not thread-safe.
+final class IdSetSubqueryRewriter {
+ private static final String IN_SUBQUERY = "insubquery";
+ private static final String IN_ID_SET = "inidset";
+
+ static final String EMPTY_ID_SET;
+
+ static {
+ try {
+ EMPTY_ID_SET = IdSets.emptyIdSet().toBase64String();
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ }
+
+ private final String _sql;
+ private final boolean _explain;
+ private final SubqueryRunner _runner;
+ // The IdSets of the subqueries, by subquery, in the order the subqueries ran
+ private final Map<String, String> _idSets = new LinkedHashMap<>();
+
+ private IdSetSubqueryRewriter(String sql, boolean explain, SubqueryRunner
runner) {
+ _sql = sql;
+ _explain = explain;
+ _runner = runner;
+ }
+
+ /// Validates and runs the subqueries for [IdSetSubqueryRewriter].
+ interface SubqueryRunner {
+ /// Returns whether the subquery validates on its own, i.e. whether it
does not refer to the query it is in.
+ boolean validates(String subquery);
+
+ /// Runs the subquery and returns the serialized IdSet it returns, or
`null` if it returns no rows or NULL.
+ @Nullable
+ String run(String subquery);
+ }
+
+ /// Runs the IdSet subqueries of the query and replaces them with the IdSets
they return, or with the empty IdSet to
+ /// explain the query. Returns the subqueries that were replaced, each once,
in the order they ran.
+ ///
+ /// @param sql the text of the query, which scalar subqueries are read from
+ /// @param query the parsed query, without the EXPLAIN
+ static List<String> rewrite(String sql, SqlNode query, boolean explain,
SubqueryRunner runner) {
+ IdSetSubqueryRewriter rewriter = new IdSetSubqueryRewriter(sql, explain,
runner);
+ rewriter.rewrite(query, Set.of());
+ return new ArrayList<>(rewriter._idSets.keySet());
+ }
+
+ /// @param withNames the names of the WITH items in scope, in lower case
+ private void rewrite(SqlNode node, Set<String> withNames) {
+ IdSetCallFinder finder = new IdSetCallFinder(withNames);
+ node.accept(finder);
+ for (IdSetCall idSetCall : finder._calls) {
+ SqlBasicCall call = idSetCall._call;
+ if (IN_SUBQUERY.equals(getFunctionName(call))) {
+ String subquery = getSubquery(call);
+ call.setOperator(new SqlUnresolvedFunction(new
SqlIdentifier("IN_ID_SET", call.getParserPosition()), null,
+ null, null, null, SqlFunctionCategory.USER_DEFINED_FUNCTION));
+ setIdSet(call, getIdSet(subquery));
+ } else {
+ SqlNode subqueryNode = call.operand(1);
+ String subquery = getText(subqueryNode);
+ // A subquery that names a WITH item cannot run on its own: it would
read the table that the item hides, if any
+ boolean runsFirst = subquery != null && !hasAnyName(subqueryNode,
idSetCall._withNames)
+ && (_idSets.containsKey(subquery) || _runner.validates(subquery));
+ if (runsFirst) {
+ setIdSet(call, getIdSet(subquery));
+ } else {
+ // The planner runs the subquery as part of the query, but the IdSet
subqueries in it still run first
+ rewrite(subqueryNode, idSetCall._withNames);
+ }
+ }
+ }
+ }
+
+ private String getIdSet(String subquery) {
+ return _idSets.computeIfAbsent(subquery, key -> {
+ String idSet = _explain ? null : _runner.run(key);
+ return idSet != null ? idSet : EMPTY_ID_SET;
+ });
+ }
+
+ private static void setIdSet(SqlBasicCall call, String idSet) {
+ call.setOperand(1, SqlLiteral.createCharString(idSet,
call.operand(1).getParserPosition()));
+ }
+
+ @Nullable
+ private static String getFunctionName(SqlCall call) {
+ return call.getOperator() instanceof SqlFunction
+ ? FunctionRegistry.canonicalize(call.getOperator().getName())
+ : null;
+ }
+
+ private static String getSubquery(SqlBasicCall call) {
+ if (call.operandCount() == 2) {
+ SqlNode subquery = call.operand(1);
+ if (subquery.getKind() == SqlKind.LITERAL_CHAIN) {
+ subquery = SqlLiteral.unchain(subquery);
+ }
+ if (subquery instanceof SqlCharStringLiteral) {
+ return ((SqlCharStringLiteral) subquery).getValueAs(String.class);
+ }
+ }
+ throw QueryErrorCode.QUERY_VALIDATION.asException(
+ "IN_SUBQUERY takes an expression and a string literal with the
subquery, got: " + call);
+ }
+
+ /// Returns the text of the subquery in the query, with its parentheses, or
`null` if the text does not parse to the
+ /// same subquery, e.g. when the parser read a different text than the given
one.
+ @Nullable
+ private String getText(SqlNode subquery) {
+ SqlParserPos pos = subquery.getParserPosition();
+ try {
+ String text = _sql.substring(SqlParserUtil.lineColToIndex(_sql,
pos.getLineNum(), pos.getColumnNum()),
+ SqlParserUtil.lineColToIndex(_sql, pos.getEndLineNum(),
pos.getEndColumnNum()) + 1);
+ SqlNode parsed =
CalciteSqlParser.compileToSqlNodeAndOptions(text).getSqlNode();
+ return parsed.equalsDeep(subquery, Litmus.IGNORE) ? text : null;
+ } catch (RuntimeException e) {
+ return null;
+ }
+ }
+
+ /// Returns whether the node has an identifier with one of the given names,
ignoring the case.
+ private static boolean hasAnyName(SqlNode node, Set<String> names) {
+ if (names.isEmpty()) {
+ return false;
+ }
+ NameFinder nameFinder = new NameFinder(names);
+ node.accept(nameFinder);
+ return nameFinder._found;
+ }
+
+ /// An IdSet subquery, and the names of the WITH items in scope where it
appears.
+ private static class IdSetCall {
+ final SqlBasicCall _call;
+ final Set<String> _withNames;
+
+ IdSetCall(SqlBasicCall call, Set<String> withNames) {
+ _call = call;
+ _withNames = withNames;
+ }
+ }
+
+ /// Finds the IdSet subqueries of a query, except those in other IdSet
subqueries.
+ private static class IdSetCallFinder extends SqlBasicVisitor<Void> {
+ final List<IdSetCall> _calls = new ArrayList<>();
+ // The names of the WITH items in scope, in lower case. Each WITH gets a
new set, so the calls can keep theirs.
+ Set<String> _withNames;
+
+ IdSetCallFinder(Set<String> withNames) {
+ _withNames = withNames;
+ }
+
+ @Override
+ public Void visit(SqlCall call) {
+ // The ORDER BY of a WITH query parses outside the WITH, but the
validator puts it in the scope of the WITH
+ SqlNode with = call instanceof SqlOrderBy ? ((SqlOrderBy) call).query :
call;
+ if (with instanceof SqlWith) {
+ Set<String> withNames = _withNames;
+ _withNames = new HashSet<>(withNames);
+ for (SqlNode withItem : ((SqlWith) with).withList) {
+ _withNames.add(((SqlWithItem)
withItem).name.getSimple().toLowerCase(Locale.ROOT));
+ }
+ super.visit(call);
+ _withNames = withNames;
+ return null;
+ }
+ if (call instanceof SqlBasicCall && call.operandCount() > 0) {
+ String functionName = getFunctionName(call);
+ if (IN_SUBQUERY.equals(functionName) ||
(IN_ID_SET.equals(functionName) && call.operandCount() == 2
+ && call.operand(1).getKind().belongsTo(SqlKind.QUERY))) {
+ _calls.add(new IdSetCall((SqlBasicCall) call, _withNames));
+ // The tested expression can hold more of them
+ call.operand(0).accept(this);
+ return null;
+ }
+ }
+ return super.visit(call);
+ }
+ }
+
+ /// Finds whether a node has an identifier with one of the given names,
ignoring the case.
+ private static class NameFinder extends SqlBasicVisitor<Void> {
+ final Set<String> _names;
+ boolean _found;
+
+ NameFinder(Set<String> names) {
+ _names = names;
+ }
+
+ @Override
+ public Void visit(SqlIdentifier id) {
+ for (String name : id.names) {
+ if (_names.contains(name.toLowerCase(Locale.ROOT))) {
+ _found = true;
+ }
+ }
+ return null;
+ }
+ }
+}
diff --git
a/pinot-broker/src/main/java/org/apache/pinot/broker/requesthandler/MultiStageBrokerRequestHandler.java
b/pinot-broker/src/main/java/org/apache/pinot/broker/requesthandler/MultiStageBrokerRequestHandler.java
index e2708b725e9..adf76c39c2e 100644
---
a/pinot-broker/src/main/java/org/apache/pinot/broker/requesthandler/MultiStageBrokerRequestHandler.java
+++
b/pinot-broker/src/main/java/org/apache/pinot/broker/requesthandler/MultiStageBrokerRequestHandler.java
@@ -28,6 +28,7 @@ import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashSet;
+import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
@@ -46,7 +47,9 @@ import javax.annotation.Nullable;
import javax.ws.rs.WebApplicationException;
import javax.ws.rs.core.HttpHeaders;
import javax.ws.rs.core.Response;
+import org.apache.calcite.sql.SqlExplain;
import org.apache.calcite.sql.SqlKind;
+import org.apache.calcite.sql.SqlNode;
import org.apache.commons.lang3.StringUtils;
import org.apache.hc.client5.http.io.HttpClientConnectionManager;
import org.apache.pinot.broker.api.AccessControl;
@@ -67,12 +70,14 @@ import
org.apache.pinot.common.response.broker.BrokerResponseNativeV2;
import org.apache.pinot.common.response.broker.QueryProcessingException;
import org.apache.pinot.common.response.broker.ResultTable;
import org.apache.pinot.common.utils.DataSchema;
+import org.apache.pinot.common.utils.DataSchema.ColumnDataType;
import org.apache.pinot.common.utils.DatabaseUtils;
import org.apache.pinot.common.utils.ExceptionUtils;
import org.apache.pinot.common.utils.NamedThreadFactory;
import org.apache.pinot.common.utils.Timer;
import org.apache.pinot.common.utils.config.QueryOptionsUtils;
import org.apache.pinot.common.utils.request.QueryFingerprintUtils;
+import org.apache.pinot.common.utils.request.RequestUtils;
import org.apache.pinot.common.utils.tls.TlsUtils;
import org.apache.pinot.core.routing.MultiClusterRoutingContext;
import org.apache.pinot.core.routing.RoutingManager;
@@ -101,9 +106,13 @@ import org.apache.pinot.spi.exception.QueryException;
import org.apache.pinot.spi.metrics.PinotMeter;
import org.apache.pinot.spi.query.QueryExecutionContext;
import org.apache.pinot.spi.query.QueryThreadContext;
+import org.apache.pinot.spi.trace.DefaultRequestContext;
import org.apache.pinot.spi.trace.QueryFingerprint;
import org.apache.pinot.spi.trace.RequestContext;
import org.apache.pinot.spi.utils.CommonConstants;
+import
org.apache.pinot.spi.utils.CommonConstants.Broker.Request.QueryOptionKey;
+import org.apache.pinot.sql.parsers.CalciteSqlParser;
+import org.apache.pinot.sql.parsers.PinotSqlType;
import org.apache.pinot.sql.parsers.SqlNodeAndOptions;
import org.apache.pinot.sql.parsers.rewriter.RlsUtils;
import org.slf4j.Logger;
@@ -131,6 +140,9 @@ public class MultiStageBrokerRequestHandler extends
BaseBrokerRequestHandler {
private static final Marker MSE_STATS_MARKER =
MarkerFactory.getMarker("MSE_STATS_MARKER");
private static final int NUM_UNAVAILABLE_SEGMENTS_TO_LOG = 10;
+ // Extra column of the EXPLAIN response with the IdSet subqueries as a JSON
array. EXPLAIN does not run them: the plan
+ // uses the empty IdSet in their place.
+ private static final String EXPLAIN_ID_SET_SUBQUERIES_FIELD =
"ID_SET_SUBQUERIES_NOT_RUN";
private final WorkerManager _workerManager;
private final WorkerManager _multiClusterWorkerManager;
@@ -449,6 +461,15 @@ public class MultiStageBrokerRequestHandler extends
BaseBrokerRequestHandler {
long activeDeadlineMs = startTimeMs + timeoutMs;
long passiveDeadlineMs = activeDeadlineMs +
getExtraPassiveTimeoutMs(options);
+ // Runs before the query context opens, because each subquery opens its
own. As in the single-stage engine, they
+ // run before the query is checked for access and quota, and each is
checked like any other query.
+ List<String> idSetSubqueries =
+ rewriteIdSetSubqueries(requestId, query, sqlNodeAndOptions,
requesterIdentity, httpHeaders, activeDeadlineMs);
+ if (!idSetSubqueries.isEmpty()) {
+ // Keeps the time of the subqueries out of the compilation time of the
query
+ queryTimer = new Timer(queryTimer.getRemainingTimeMs(),
TimeUnit.MILLISECONDS);
+ }
+
QueryExecutionContext executionContext =
new QueryExecutionContext(QueryExecutionContext.QueryType.MSE,
requestId, cid, workloadName, startTimeMs,
activeDeadlineMs, passiveDeadlineMs, _brokerId, _brokerId,
queryHash);
@@ -468,7 +489,7 @@ public class MultiStageBrokerRequestHandler extends
BaseBrokerRequestHandler {
prepareCompiledQueryForPlanning(compiledQuery, requestId,
requestContext, httpHeaders);
if (sqlNodeAndOptions.getSqlNode().getKind() == SqlKind.EXPLAIN) {
- return explain(compiledQuery, requestId, requestContext, queryTimer);
+ return explain(compiledQuery, requestId, requestContext, queryTimer,
idSetSubqueries);
} else {
return query(compiledQuery, requestId, requesterIdentity,
requestContext, httpHeaders, queryTimer,
rlsFiltersApplied.get(), queryWasLogged, workloadName);
@@ -673,8 +694,10 @@ public class MultiStageBrokerRequestHandler extends
BaseBrokerRequestHandler {
/// Explains the query and returns the broker response.
///
/// Throws using the same conventions as handleRequestThrowing.
+ ///
+ /// @param idSetSubqueries the IdSet subqueries that did not run, see
[IdSetSubqueryRewriter]
private BrokerResponse explain(QueryEnvironment.CompiledQuery query, long
requestId, RequestContext requestContext,
- Timer timer)
+ Timer timer, List<String> idSetSubqueries)
throws WebApplicationException, QueryException {
Map<String, String> queryOptions = query.getOptions();
@@ -689,9 +712,158 @@ public class MultiStageBrokerRequestHandler extends
BaseBrokerRequestHandler {
() -> query.explain(requestId, fragmentToPlanNode), timer);
String plan = queryPlanResult.getExplainPlan();
Map<String, String> extraFields = queryPlanResult.getExtraFields();
+ if (!idSetSubqueries.isEmpty()) {
+ ArrayNode subqueries = JsonNodeFactory.instance.arrayNode();
+ idSetSubqueries.forEach(subqueries::add);
+ extraFields = new LinkedHashMap<>(extraFields);
+ extraFields.put(EXPLAIN_ID_SET_SUBQUERIES_FIELD, subqueries.toString());
+ }
return constructMultistageExplainPlan(query.getTextQuery(), plan,
extraFields);
}
+ /// Runs the IdSet subqueries of the query, or not to explain it, and
replaces them with their IdSets. See
+ /// [IdSetSubqueryRewriter]. Returns the subqueries that were replaced.
+ private List<String> rewriteIdSetSubqueries(long requestId, String query,
SqlNodeAndOptions sqlNodeAndOptions,
+ @Nullable RequesterIdentity requesterIdentity, @Nullable HttpHeaders
httpHeaders, long deadlineMs) {
+ SqlNode sqlNode = sqlNodeAndOptions.getSqlNode();
+ boolean explain = sqlNode.getKind() == SqlKind.EXPLAIN;
+ SqlNode queryNode = explain ? ((SqlExplain) sqlNode).getExplicandum() :
sqlNode;
+ Map<String, String> queryOptions = sqlNodeAndOptions.getOptions();
+ return IdSetSubqueryRewriter.rewrite(query, queryNode, explain, new
IdSetSubqueryRewriter.SubqueryRunner() {
+ @Override
+ public boolean validates(String subquery) {
+ return validatesOnItsOwn(requestId, subquery, queryOptions,
httpHeaders, deadlineMs);
+ }
+
+ @Nullable
+ @Override
+ public String run(String subquery) {
+ return runIdSetSubquery(requestId, subquery, queryOptions,
requesterIdentity, httpHeaders, deadlineMs);
+ }
+ });
+ }
+
+ /// Returns whether the subquery validates on its own, i.e. whether it does
not refer to the query it is in.
+ /// Validation does not optimize the subquery, so it is quick enough to run
without a timeout.
+ ///
+ /// Any failure, e.g. from an invalid option the subquery inherits, means
no: the subquery then stays in the query,
+ /// and compiling the query reports the problem.
+ private boolean validatesOnItsOwn(long requestId, String subquery,
Map<String, String> queryOptions,
+ @Nullable HttpHeaders httpHeaders, long deadlineMs) {
+ getTimeLeftMs(subquery, deadlineMs);
+ try {
+ SqlNodeAndOptions sqlNodeAndOptions =
CalciteSqlParser.compileToSqlNodeAndOptions(subquery);
+ RequestUtils.setOptions(sqlNodeAndOptions, queryOptions);
+ QueryEnvironment queryEnv = new QueryEnvironment(
+ getQueryEnvConf(httpHeaders, sqlNodeAndOptions.getOptions(),
requestId), _multiClusterRoutingContext);
+ queryEnv.validate(sqlNodeAndOptions);
+ return true;
+ } catch (RuntimeException e) {
+ return false;
+ }
+ }
+
+ /// Runs an IdSet subquery as a query of its own, and returns the serialized
IdSet it returns, or `null` if it returns
+ /// no rows or NULL.
+ ///
+ /// The subquery inherits the options of the query, which the options set in
the subquery override, and the time left
+ /// until the deadline of the query. It runs like any other query: it is
checked for access and quota, and it can be
+ /// cancelled. As it inherits the client query id, cancelling the query by
that id cancels the subquery, which fails
+ /// the query.
+ @Nullable
+ private String runIdSetSubquery(long requestId, String subquery, Map<String,
String> queryOptions,
+ @Nullable RequesterIdentity requesterIdentity, @Nullable HttpHeaders
httpHeaders, long deadlineMs) {
+ SqlNodeAndOptions sqlNodeAndOptions;
+ try {
+ sqlNodeAndOptions = RequestUtils.parseQuery(subquery);
+ } catch (Exception e) {
+ throw new QueryException(QueryErrorCode.fromThrowable(e,
QueryErrorCode.SQL_PARSING),
+ "Failed to parse subquery: " + subquery + ": " + e.getMessage(), e);
+ }
+ if (sqlNodeAndOptions.getSqlType() != PinotSqlType.DQL
+ || !sqlNodeAndOptions.getSqlNode().getKind().belongsTo(SqlKind.QUERY))
{
+ throw QueryErrorCode.QUERY_VALIDATION.asException("Subquery is not a
query: " + subquery);
+ }
+ RequestUtils.setOptions(sqlNodeAndOptions, queryOptions);
+ Map<String, String> options = sqlNodeAndOptions.getOptions();
+ long timeLeftMs = getTimeLeftMs(subquery, deadlineMs);
+ Long timeoutMs = QueryOptionsUtils.getTimeoutMs(options);
+ options.put(QueryOptionKey.TIMEOUT_MS,
+ Long.toString(timeoutMs != null ? Math.min(timeoutMs, timeLeftMs) :
timeLeftMs));
+ // The rows of the subquery are its result
+ options.remove(QueryOptionKey.DROP_RESULTS);
+
+ // Like BaseBrokerRequestHandler.handleRequest, except for the first
access check and the application quota, which
+ // the query passed
+ long subqueryRequestId = _requestIdGenerator.get();
+ RequestContext requestContext = new DefaultRequestContext();
+ requestContext.setRequestArrivalTimeMillis(System.currentTimeMillis());
+ requestContext.setBrokerId(_brokerId);
+ requestContext.setRequestId(subqueryRequestId);
+ requestContext.setQuery(subquery);
+ setTrackedHeadersInRequestContext(requestContext, httpHeaders,
_trackedHeaders);
+ LOGGER.info("Request {} runs a subquery as request {}", requestId,
subqueryRequestId);
+ BrokerResponse response = handleRequest(subqueryRequestId, subquery,
sqlNodeAndOptions,
+
JsonNodeFactory.instance.objectNode().put(CommonConstants.Broker.Request.SQL,
subquery), requesterIdentity,
+ requestContext, httpHeaders, _accessControlFactory.create());
+ response.setBrokerId(_brokerId);
+ response.setRequestId(Long.toString(subqueryRequestId));
+ onQueryCompletion(requestContext, response);
+
+ if (!response.getExceptions().isEmpty()) {
+ QueryProcessingException exception = response.getExceptions().get(0);
+ throw new
QueryException(QueryErrorCode.fromErrorCode(exception.getErrorCode()),
+ "Subquery failed: " + subquery + ": " + exception.getMessage());
+ }
+ // An IdSet built from part of the rows would make the query miss rows
without an error
+ if (response.isPartialResult()) {
+ List<String> reasons = getPartialResultReasons(response);
+ throw QueryErrorCode.QUERY_EXECUTION.asException(
+ "Subquery returned a partial result" + (reasons.isEmpty() ? "" : " "
+ reasons) + ": " + subquery);
+ }
+ ResultTable resultTable = response.getResultTable();
+ DataSchema dataSchema = resultTable != null ? resultTable.getDataSchema()
: null;
+ if (dataSchema == null || dataSchema.size() != 1 ||
dataSchema.getColumnDataType(0) != ColumnDataType.STRING) {
+ throw QueryErrorCode.QUERY_VALIDATION.asException(
+ "Subquery must return one STRING column, the serialized IdSet, got:
" + dataSchema + " for: " + subquery);
+ }
+ List<Object[]> rows = resultTable.getRows();
+ if (rows.size() > 1) {
+ throw QueryErrorCode.QUERY_EXECUTION.asException(
+ "Subquery must return at most one row, got: " + rows.size() + " for:
" + subquery);
+ }
+ return rows.isEmpty() ? null : (String) rows.get(0)[0];
+ }
+
+ /// Returns the known reasons why the response holds a partial result, for
the error message.
+ private static List<String> getPartialResultReasons(BrokerResponse response)
{
+ List<String> reasons = new ArrayList<>();
+ if (response.isNumGroupsLimitReached()) {
+ reasons.add("numGroupsLimitReached");
+ }
+ if (response.isMaxRowsInJoinReached()) {
+ reasons.add("maxRowsInJoinReached");
+ }
+ if (response.isMaxRowsInWindowReached()) {
+ reasons.add("maxRowsInWindowReached");
+ }
+ if (response.isMseLiteLeafStageLimitReached()) {
+ reasons.add("mseLiteLeafStageLimitReached");
+ }
+ if (response instanceof BrokerResponseNativeV2) {
+ reasons.addAll(((BrokerResponseNativeV2)
response).getEarlyTerminationReasons());
+ }
+ return reasons;
+ }
+
+ private static long getTimeLeftMs(String subquery, long deadlineMs) {
+ long timeLeftMs = deadlineMs - System.currentTimeMillis();
+ if (timeLeftMs <= 0) {
+ throw QueryErrorCode.BROKER_TIMEOUT.asException("Timed out before
running subquery: " + subquery);
+ }
+ return timeLeftMs;
+ }
+
private BrokerResponse query(QueryEnvironment.CompiledQuery query, long
requestId,
RequesterIdentity requesterIdentity, RequestContext requestContext,
HttpHeaders httpHeaders, Timer timer,
boolean rlsFiltersApplied, boolean queryWasLogged, String workloadName)
diff --git
a/pinot-broker/src/test/java/org/apache/pinot/broker/requesthandler/IdSetSubqueryRewriterTest.java
b/pinot-broker/src/test/java/org/apache/pinot/broker/requesthandler/IdSetSubqueryRewriterTest.java
new file mode 100644
index 00000000000..5ffc16e2291
--- /dev/null
+++
b/pinot-broker/src/test/java/org/apache/pinot/broker/requesthandler/IdSetSubqueryRewriterTest.java
@@ -0,0 +1,309 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.pinot.broker.requesthandler;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import javax.annotation.Nullable;
+import org.apache.calcite.sql.SqlExplain;
+import org.apache.calcite.sql.SqlKind;
+import org.apache.calcite.sql.SqlNode;
+import org.apache.calcite.util.Litmus;
+import org.apache.pinot.spi.exception.QueryErrorCode;
+import org.apache.pinot.spi.exception.QueryException;
+import org.apache.pinot.sql.parsers.CalciteSqlParser;
+import org.testng.annotations.Test;
+
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertTrue;
+import static org.testng.Assert.expectThrows;
+
+
+/// Tests [IdSetSubqueryRewriter] on parsed queries, with a fake runner that
returns a given IdSet per subquery.
+public class IdSetSubqueryRewriterTest {
+ private static final String EMPTY = IdSetSubqueryRewriter.EMPTY_ID_SET;
+
+ @Test
+ public void testRewritesInSubqueryInFilters() {
+ FakeRunner runner = new FakeRunner().returning("SELECT IDSET(col1) FROM
b", "set1")
+ .returning("SELECT IDSET(col3) FROM b WHERE col2 = 'x'", "set2");
+ List<String> subqueries = assertRewrite(
+ "SELECT col1 FROM a WHERE IN_SUBQUERY(col1, 'SELECT IDSET(col1) FROM
b') = 1 "
+ + "AND NOT IN_SUBQUERY(col3, 'SELECT IDSET(col3) FROM b WHERE col2
= ''x''')",
+ "SELECT col1 FROM a WHERE IN_ID_SET(col1, 'set1') = 1 AND NOT
IN_ID_SET(col3, 'set2')", false, runner);
+ assertEquals(subqueries, List.of("SELECT IDSET(col1) FROM b", "SELECT
IDSET(col3) FROM b WHERE col2 = 'x'"));
+ assertEquals(runner._ran, subqueries);
+ assertEquals(runner._validated, List.of());
+ }
+
+ @Test
+ public void testRewritesInSubqueryInEveryClause() {
+ FakeRunner runner = new FakeRunner().returning("q1",
"set1").returning("q2", "set2").returning("q3", "set3")
+ .returning("q4", "set4").returning("q5", "set5");
+ assertRewrite(
+ "SELECT IN_SUBQUERY(a.col1, 'q1'), COUNT(*) FROM a JOIN b ON a.col2 =
b.col2 AND IN_SUBQUERY(b.col3, 'q2') = 1 "
+ + "GROUP BY 1 HAVING IN_SUBQUERY(MAX(a.col3), 'q3') = 1 "
+ + "ORDER BY CASE WHEN IN_SUBQUERY(MIN(b.col1), 'q4') = 1 THEN 1
ELSE IN_SUBQUERY(1, 'q5') END",
+ "SELECT IN_ID_SET(a.col1, 'set1'), COUNT(*) FROM a JOIN b ON a.col2 =
b.col2 AND IN_ID_SET(b.col3, 'set2') = 1 "
+ + "GROUP BY 1 HAVING IN_ID_SET(MAX(a.col3), 'set3') = 1 "
+ + "ORDER BY CASE WHEN IN_ID_SET(MIN(b.col1), 'set4') = 1 THEN 1
ELSE IN_ID_SET(1, 'set5') END",
+ false, runner);
+ assertEquals(runner._ran.size(), 5);
+ }
+
+ @Test
+ public void testRewritesInSubqueryInNestedQueries() {
+ FakeRunner runner = new FakeRunner().returning("q1",
"set1").returning("q2", "set2").returning("q3", "set3")
+ .returning("q4", "set4");
+ assertRewrite(
+ "WITH w AS (SELECT col1 FROM b WHERE IN_SUBQUERY(col1, 'q1') = 1) "
+ + "SELECT col1 FROM (SELECT col1 FROM a WHERE IN_SUBQUERY(col1,
'q2') = 1) "
+ + "WHERE col1 IN (SELECT col1 FROM w) "
+ + "AND col1 NOT IN (SELECT col1 FROM c WHERE IN_SUBQUERY(col1,
'q3') = 0) "
+ + "UNION ALL SELECT col1 FROM d WHERE IN_SUBQUERY(col1, 'q4') = 1",
+ "WITH w AS (SELECT col1 FROM b WHERE IN_ID_SET(col1, 'set1') = 1) "
+ + "SELECT col1 FROM (SELECT col1 FROM a WHERE IN_ID_SET(col1,
'set2') = 1) "
+ + "WHERE col1 IN (SELECT col1 FROM w) "
+ + "AND col1 NOT IN (SELECT col1 FROM c WHERE IN_ID_SET(col1,
'set3') = 0) "
+ + "UNION ALL SELECT col1 FROM d WHERE IN_ID_SET(col1, 'set4') = 1",
+ false, runner);
+ assertEquals(runner._ran.size(), 4);
+ }
+
+ @Test
+ public void testMatchesEveryNameOfInSubquery() {
+ FakeRunner runner = new FakeRunner().returning("q1",
"set1").returning("q2", "set2").returning("q3", "set3");
+ assertRewrite("SELECT col1 FROM a WHERE INSUBQUERY(col1, 'q1') = 1 AND
inSubquery(col2, 'q2') = 1 "
+ + "AND in_sub_query(col3, 'q3') = 1",
+ "SELECT col1 FROM a WHERE IN_ID_SET(col1, 'set1') = 1 AND
IN_ID_SET(col2, 'set2') = 1 "
+ + "AND IN_ID_SET(col3, 'set3') = 1", false, runner);
+ }
+
+ @Test
+ public void testUsesTheEmptyIdSetForNoResult() {
+ FakeRunner runner = new FakeRunner().returning("q1", null);
+ assertRewrite("SELECT col1 FROM a WHERE IN_SUBQUERY(col1, 'q1') = 0",
+ "SELECT col1 FROM a WHERE IN_ID_SET(col1, '" + EMPTY + "') = 0",
false, runner);
+ }
+
+ @Test
+ public void testRunsScalarSubqueries() {
+ String subquery1 = "SELECT IDSET(col1) FROM b WHERE col3 > 1";
+ String subquery2 = "SELECT IDSET(col2, 'expectedInsertions=10') FROM
(SELECT col2 FROM c UNION SELECT col2 FROM d)";
+ String subquery3 = "WITH w AS (SELECT col3 FROM b) SELECT IDSET(col3) FROM
w";
+ String subquery4 = "SELECT IDSET(col1) FROM b\n -- a comment\n GROUP BY
col2\n ORDER BY col2 LIMIT 1";
+ // The text of a scalar subquery includes its parentheses
+ List<String> expected =
+ List.of("(" + subquery1 + ")", "(" + subquery2 + ")", "(" + subquery3
+ ")", "(\n " + subquery4 + "\n)");
+ FakeRunner runner = new FakeRunner().returning(expected.get(0),
"set1").returning(expected.get(1), "set2")
+ .returning(expected.get(2), "set3").returning(expected.get(3), "set4");
+ List<String> subqueries = assertRewrite(
+ "SET timeoutMs = 1000;\nSELECT col1 FROM a WHERE IN_ID_SET(col1, " +
expected.get(0) + ") = 1\n"
+ + "AND IN_ID_SET(col2, " + expected.get(1) + ") = 1 AND
inIdSet(col3, " + expected.get(2) + ") = 1\n"
+ + "AND IN_ID_SET(col1, " + expected.get(3) + ") = 0",
+ "SELECT col1 FROM a WHERE IN_ID_SET(col1, 'set1') = 1 AND
IN_ID_SET(col2, 'set2') = 1 "
+ + "AND inIdSet(col3, 'set3') = 1 AND IN_ID_SET(col1, 'set4') = 0",
false, runner);
+ assertEquals(subqueries, expected);
+ assertEquals(runner._validated, expected);
+ assertEquals(runner._ran, expected);
+ }
+
+ @Test
+ public void testLeavesCorrelatedScalarSubqueries() {
+ String correlated = "(SELECT IDSET(b.col1) FROM b WHERE b.col2 = a.col2
AND IN_SUBQUERY(b.col3, 'q1') = 1)";
+ FakeRunner runner = new
FakeRunner().correlated(correlated).returning("q1", "set1");
+ List<String> subqueries = assertRewrite("SELECT col1 FROM a WHERE
IN_ID_SET(a.col1, " + correlated + ") = 1",
+ "SELECT col1 FROM a WHERE IN_ID_SET(a.col1, (SELECT IDSET(b.col1) FROM
b WHERE b.col2 = a.col2 "
+ + "AND IN_ID_SET(b.col3, 'set1') = 1)) = 1", false, runner);
+ // The IN_SUBQUERY in the correlated subquery still runs first
+ assertEquals(subqueries, List.of("q1"));
+ assertEquals(runner._validated, List.of(correlated));
+ assertEquals(runner._ran, List.of("q1"));
+ }
+
+ @Test
+ public void testLeavesScalarSubqueriesWhoseTextDiffers() {
+ // The parser read another text than the given one, e.g. after the broker
sanitized it
+ String sql = "SELECT col1 FROM a WHERE IN_ID_SET(col1, (SELECT IDSET(col1)
FROM b)) = 1";
+ SqlNode query = parse(sql);
+ FakeRunner runner = new FakeRunner();
+ List<String> subqueries =
+ IdSetSubqueryRewriter.rewrite(sql.replace("FROM b", "FROM c"), query,
false, runner);
+ assertEquals(subqueries, List.of());
+ assertEquals(runner._validated, List.of());
+ assertTrue(query.equalsDeep(parse(sql), Litmus.THROW));
+ }
+
+ @Test
+ public void testExplainRunsNoSubqueries() {
+ String scalarSubquery = "(SELECT IDSET(col1) FROM b)";
+ String correlated = "(SELECT IDSET(b.col1) FROM b WHERE b.col2 = a.col2)";
+ FakeRunner runner = new FakeRunner().correlated(correlated);
+ List<String> subqueries = assertRewrite(
+ "EXPLAIN PLAN FOR SELECT col1 FROM a WHERE IN_SUBQUERY(col1, 'q1') = 1
AND IN_ID_SET(col2, "
+ + scalarSubquery + ") = 1 AND IN_ID_SET(col3, " + correlated + ")
= 1",
+ "SELECT col1 FROM a WHERE IN_ID_SET(col1, '" + EMPTY + "') = 1 AND
IN_ID_SET(col2, '" + EMPTY + "') = 1 "
+ + "AND IN_ID_SET(col3, " + correlated + ") = 1", true, runner);
+ assertEquals(subqueries, List.of("q1", scalarSubquery));
+ assertEquals(runner._validated, List.of(scalarSubquery, correlated));
+ assertEquals(runner._ran, List.of());
+ }
+
+ @Test
+ public void testLeavesQueriesWithoutIdSetSubqueries() {
+ FakeRunner runner = new FakeRunner();
+ String sql = "SELECT col1 FROM a WHERE IN_ID_SET(col1, 'AwAAAA==') = 1 AND
col2 IN (SELECT col2 FROM b) "
+ + "AND col3 = (SELECT MAX(col3) FROM c)";
+ assertEquals(assertRewrite(sql, sql, false, runner), List.of());
+ assertEquals(runner._validated, List.of());
+ assertEquals(runner._ran, List.of());
+ }
+
+ @Test
+ public void testRejectsInvalidInSubqueryCalls() {
+ for (String sql : List.of("SELECT col1 FROM a WHERE IN_SUBQUERY(col1) = 1",
+ "SELECT col1 FROM a WHERE IN_SUBQUERY(col1, 'q1', 'q2') = 1",
+ "SELECT col1 FROM a WHERE IN_SUBQUERY(col1, col2) = 1",
+ "SELECT col1 FROM a WHERE IN_SUBQUERY(col1, (SELECT 'q1')) = 1")) {
+ QueryException exception = expectThrows(QueryException.class,
+ () -> IdSetSubqueryRewriter.rewrite(sql, parse(sql), false, new
FakeRunner()));
+ assertEquals(exception.getErrorCode(), QueryErrorCode.QUERY_VALIDATION);
+ assertTrue(exception.getMessage().contains("IN_SUBQUERY takes an
expression and a string literal"),
+ exception.getMessage());
+ }
+ }
+
+ @Test
+ public void testRunsEachSubqueryOnce() {
+ String scalarSubquery = "(SELECT IDSET(col1) FROM b)";
+ FakeRunner runner = new FakeRunner().returning("q1",
"set1").returning(scalarSubquery, "set2");
+ List<String> subqueries = assertRewrite(
+ "SELECT col1 FROM a WHERE IN_SUBQUERY(col1, 'q1') = 1 OR
IN_SUBQUERY(col2, 'q1') = 1 "
+ + "OR IN_ID_SET(col1, " + scalarSubquery + ") = 1 OR
IN_ID_SET(col2, " + scalarSubquery + ") = 1",
+ "SELECT col1 FROM a WHERE IN_ID_SET(col1, 'set1') = 1 OR
IN_ID_SET(col2, 'set1') = 1 "
+ + "OR IN_ID_SET(col1, 'set2') = 1 OR IN_ID_SET(col2, 'set2') = 1",
false, runner);
+ assertEquals(subqueries, List.of("q1", scalarSubquery));
+ assertEquals(runner._ran, List.of("q1", scalarSubquery));
+ assertEquals(runner._validated, List.of(scalarSubquery));
+ }
+
+ @Test
+ public void testLeavesScalarSubqueriesThatNameWithItems() {
+ // On its own, a subquery that names the WITH item b would read table b
+ String namesWithItem = "(SELECT IDSET(col1) FROM b)";
+ String other = "(SELECT IDSET(col3) FROM c)";
+ FakeRunner runner = new FakeRunner().returning(other, "set1");
+ assertRewrite("WITH b AS (SELECT col1 FROM d) SELECT col1 FROM a WHERE
IN_ID_SET(col1, " + namesWithItem
+ + ") = 1 AND IN_ID_SET(col3, " + other + ") = 1",
+ "WITH b AS (SELECT col1 FROM d) SELECT col1 FROM a WHERE
IN_ID_SET(col1, " + namesWithItem
+ + ") = 1 AND IN_ID_SET(col3, 'set1') = 1", false, runner);
+ assertEquals(runner._validated, List.of(other));
+
+ // The WITH items stay in scope in a subquery that stays, so the
subqueries in it stay too
+ String correlated = "(SELECT IDSET(c.col1) FROM c WHERE c.col2 = a.col2
AND IN_ID_SET(c.col3, " + namesWithItem
+ + ") = 1)";
+ runner = new FakeRunner();
+ String sql = "WITH b AS (SELECT col1 FROM d) SELECT col1 FROM a WHERE
IN_ID_SET(a.col1, " + correlated + ") = 1";
+ assertRewrite(sql, sql, false, runner);
+ assertEquals(runner._validated, List.of());
+ assertEquals(runner._ran, List.of());
+
+ // The ORDER BY of a WITH query parses outside the WITH, but the WITH
items are in its scope too
+ runner = new FakeRunner();
+ sql = "WITH b AS (SELECT col1 FROM d) SELECT col1 FROM a ORDER BY CASE
WHEN IN_ID_SET(col1, " + namesWithItem
+ + ") = 1 THEN 0 ELSE 1 END LIMIT 10";
+ assertRewrite(sql, sql, false, runner);
+ assertEquals(runner._validated, List.of());
+
+ // But not outside the query that has the WITH
+ runner = new FakeRunner().returning(namesWithItem, "set1");
+ assertRewrite("SELECT col1 FROM (WITH b AS (SELECT col1 FROM d) SELECT
col1 FROM b) WHERE IN_ID_SET(col1, "
+ + namesWithItem + ") = 1",
+ "SELECT col1 FROM (WITH b AS (SELECT col1 FROM d) SELECT col1 FROM b)
WHERE IN_ID_SET(col1, 'set1') = 1",
+ false, runner);
+ }
+
+ @Test
+ public void testRewritesIdSetSubqueriesInTheTestedExpression() {
+ FakeRunner runner = new FakeRunner().returning("q1",
"set1").returning("q2", "set2");
+ assertRewrite(
+ "SELECT col1 FROM a WHERE IN_SUBQUERY(CASE WHEN IN_SUBQUERY(col2,
'q2') = 1 THEN col1 END, 'q1') = 1",
+ "SELECT col1 FROM a WHERE IN_ID_SET(CASE WHEN IN_ID_SET(col2, 'set2')
= 1 THEN col1 END, 'set1') = 1",
+ false, runner);
+ assertEquals(runner._ran, List.of("q1", "q2"));
+ }
+
+ @Test
+ public void testReadsSubqueriesSplitAcrossLiterals() {
+ FakeRunner runner = new FakeRunner().returning("SELECT IDSET(col1) FROM
b", "set1");
+ assertRewrite("SELECT col1 FROM a WHERE IN_SUBQUERY(col1, 'SELECT
IDSET(col1) '\n'FROM b') = 1",
+ "SELECT col1 FROM a WHERE IN_ID_SET(col1, 'set1') = 1", false, runner);
+ }
+
+ /// Rewrites the query, checks that it becomes the expected query, and
returns the replaced subqueries.
+ private static List<String> assertRewrite(String sql, String expectedSql,
boolean explain, FakeRunner runner) {
+ SqlNode query = parse(sql);
+ assertEquals(query.getKind() == SqlKind.EXPLAIN, explain);
+ if (explain) {
+ query = ((SqlExplain) query).getExplicandum();
+ }
+ List<String> subqueries = IdSetSubqueryRewriter.rewrite(sql, query,
explain, runner);
+ assertTrue(query.equalsDeep(parse(expectedSql), Litmus.THROW));
+ return subqueries;
+ }
+
+ private static SqlNode parse(String sql) {
+ return CalciteSqlParser.compileToSqlNodeAndOptions(sql).getSqlNode();
+ }
+
+ private static class FakeRunner implements
IdSetSubqueryRewriter.SubqueryRunner {
+ final Map<String, String> _results = new HashMap<>();
+ final Set<String> _correlated = new HashSet<>();
+ final List<String> _validated = new ArrayList<>();
+ final List<String> _ran = new ArrayList<>();
+
+ FakeRunner returning(String subquery, @Nullable String idSet) {
+ _results.put(subquery, idSet);
+ return this;
+ }
+
+ FakeRunner correlated(String subquery) {
+ _correlated.add(subquery);
+ return this;
+ }
+
+ @Override
+ public boolean validates(String subquery) {
+ _validated.add(subquery);
+ return !_correlated.contains(subquery);
+ }
+
+ @Nullable
+ @Override
+ public String run(String subquery) {
+ _ran.add(subquery);
+ assertTrue(_results.containsKey(subquery), "Unexpected subquery: " +
subquery);
+ return _results.get(subquery);
+ }
+ }
+}
diff --git
a/pinot-broker/src/test/java/org/apache/pinot/broker/requesthandler/MultiStageIdSetSubqueryTest.java
b/pinot-broker/src/test/java/org/apache/pinot/broker/requesthandler/MultiStageIdSetSubqueryTest.java
new file mode 100644
index 00000000000..54d7eb8471d
--- /dev/null
+++
b/pinot-broker/src/test/java/org/apache/pinot/broker/requesthandler/MultiStageIdSetSubqueryTest.java
@@ -0,0 +1,491 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.pinot.broker.requesthandler;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+import javax.annotation.Nullable;
+import javax.ws.rs.WebApplicationException;
+import javax.ws.rs.core.HttpHeaders;
+import org.apache.pinot.broker.api.AccessControl;
+import org.apache.pinot.broker.broker.AccessControlFactory;
+import org.apache.pinot.broker.broker.AllowAllAccessControlFactory;
+import org.apache.pinot.broker.queryquota.QueryQuotaManager;
+import org.apache.pinot.common.failuredetector.FailureDetector;
+import org.apache.pinot.common.metrics.BrokerMetrics;
+import org.apache.pinot.common.response.BrokerResponse;
+import org.apache.pinot.common.response.broker.BrokerResponseNativeV2;
+import org.apache.pinot.common.response.broker.QueryProcessingException;
+import org.apache.pinot.common.response.broker.ResultTable;
+import org.apache.pinot.common.utils.DataSchema;
+import org.apache.pinot.common.utils.DataSchema.ColumnDataType;
+import org.apache.pinot.common.utils.request.RequestUtils;
+import org.apache.pinot.core.query.utils.idset.IdSet;
+import org.apache.pinot.core.query.utils.idset.IdSets;
+import org.apache.pinot.core.routing.MockRoutingManagerFactory;
+import org.apache.pinot.core.routing.RoutingManager;
+import org.apache.pinot.query.QueryEnvironmentTestBase;
+import org.apache.pinot.query.routing.WorkerManager;
+import org.apache.pinot.spi.accounting.ThreadAccountantUtils;
+import org.apache.pinot.spi.auth.TableAuthorizationResult;
+import org.apache.pinot.spi.auth.broker.RequesterIdentity;
+import org.apache.pinot.spi.data.FieldSpec.DataType;
+import org.apache.pinot.spi.env.PinotConfiguration;
+import
org.apache.pinot.spi.eventlistener.query.BrokerQueryEventListenerFactory;
+import org.apache.pinot.spi.exception.QueryErrorCode;
+import org.apache.pinot.spi.exception.QueryException;
+import org.apache.pinot.spi.trace.DefaultRequestContext;
+import org.apache.pinot.spi.trace.RequestContext;
+import
org.apache.pinot.spi.utils.CommonConstants.Broker.Request.QueryOptionKey;
+import org.apache.pinot.spi.utils.CommonConstants.MultiStageQueryRunner;
+import org.apache.pinot.spi.utils.NetUtils;
+import org.apache.pinot.sql.parsers.SqlNodeAndOptions;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertFalse;
+import static org.testng.Assert.assertNotEquals;
+import static org.testng.Assert.assertSame;
+import static org.testng.Assert.assertTrue;
+import static org.testng.Assert.expectThrows;
+
+
+/// Tests how [MultiStageBrokerRequestHandler] runs the IdSet subqueries of a
query, see [IdSetSubqueryRewriter].
+///
+/// Queries plan against the tables of [QueryEnvironmentTestBase]. The handler
answers the subqueries it is given
+/// responses for, and no server runs, so a query that gets past planning
stops at the mocked throttler.
+public class MultiStageIdSetSubqueryTest {
+ private static final long REQUEST_ID = 1L;
+ private static final String SUBQUERY = "SELECT IDSET(col1) FROM b";
+ // The text of a scalar subquery includes its parentheses
+ private static final String SCALAR_SUBQUERY = "(" + SUBQUERY + ")";
+ private static final String ID_SET;
+
+ static {
+ IdSet idSet = IdSets.create(DataType.STRING);
+ idSet.add("foo");
+ try {
+ ID_SET = idSet.toBase64String();
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ private final RequesterIdentity _requesterIdentity =
mock(RequesterIdentity.class);
+ private final HttpHeaders _httpHeaders = mock(HttpHeaders.class);
+ // Responses to the subqueries, and errors they throw, by subquery
+ private final Map<String, BrokerResponse> _subqueryResponses = new
HashMap<>();
+ private final Map<String, QueryException> _subqueryErrors = new HashMap<>();
+ // All the requests the handler runs, including the queries of the tests
+ private final List<Request> _requests = new ArrayList<>();
+ private final List<RequestContext> _completedRequests = new ArrayList<>();
+
+ @BeforeMethod
+ public void setUp() {
+ _subqueryResponses.clear();
+ _subqueryErrors.clear();
+ _requests.clear();
+ _completedRequests.clear();
+ }
+
+ @Test
+ public void testSubqueryInheritsTheOptionsAndTheTimeLeft()
+ throws Exception {
+ String subquery = "SET numGroupsLimit = 8; " + SUBQUERY;
+ _subqueryResponses.put(subquery, idSetResponse(ID_SET));
+ String sql = "SET timeoutMs = 60000; SET clientQueryId = 'myQuery'; SET
dropResults = true; "
+ + "SET numGroupsLimit = 7; SET maxRowsInJoin = 9; "
+ + "SELECT COUNT(*) FROM a WHERE IN_SUBQUERY(col1, '" + subquery + "')
= 1";
+ SqlNodeAndOptions sqlNodeAndOptions = RequestUtils.parseQuery(sql);
+ long startTimeMs = System.currentTimeMillis();
+ handleRequest(newHandler(new AllowAllAccessControlFactory()), sql,
sqlNodeAndOptions);
+
+ Request request = getSubqueryRequest();
+ assertNotEquals(request._requestId, REQUEST_ID);
+ assertEquals(request._query, subquery);
+ assertEquals(request._requestContext.getRequestId(), request._requestId);
+ assertEquals(request._requestContext.getQuery(), subquery);
+ assertTrue(request._requestContext.getRequestArrivalTimeMillis() >=
startTimeMs);
+ assertSame(request._requesterIdentity, _requesterIdentity);
+ assertSame(request._httpHeaders, _httpHeaders);
+ Map<String, String> options = request._options;
+ // The options set in the subquery override those of the query
+ assertEquals(options.get(QueryOptionKey.NUM_GROUPS_LIMIT), "8");
+ assertEquals(options.get(QueryOptionKey.MAX_ROWS_IN_JOIN), "9");
+ // The client query id lets the client cancel the subquery, which fails
the query
+ assertEquals(options.get(QueryOptionKey.CLIENT_QUERY_ID), "myQuery");
+ assertFalse(options.containsKey(QueryOptionKey.DROP_RESULTS));
+ long timeoutMs = Long.parseLong(options.get(QueryOptionKey.TIMEOUT_MS));
+ assertTrue(timeoutMs > 0 && timeoutMs <= 60_000, "Timeout: " + timeoutMs);
+
+ // The query uses the IdSet, and its options do not change
+ String rewrittenQuery = sqlNodeAndOptions.getSqlNode().toString();
+ assertTrue(rewrittenQuery.contains(ID_SET), rewrittenQuery);
+ assertFalse(rewrittenQuery.toUpperCase().contains("IN_SUBQUERY"),
rewrittenQuery);
+
assertEquals(sqlNodeAndOptions.getOptions().get(QueryOptionKey.NUM_GROUPS_LIMIT),
"7");
+
assertEquals(sqlNodeAndOptions.getOptions().get(QueryOptionKey.DROP_RESULTS),
"true");
+
+ // The subquery is reported to the query event listener like any other
query
+ assertEquals(_completedRequests.size(), 1);
+ assertSame(_completedRequests.get(0), request._requestContext);
+ }
+
+ @Test
+ public void testSubqueryGetsTheTimeLeftOfTheQuery()
+ throws Exception {
+ _subqueryResponses.put(SUBQUERY, idSetResponse(ID_SET));
+ String sql = "SET timeoutMs = 10000; SELECT COUNT(*) FROM a WHERE
IN_SUBQUERY(col1, '" + SUBQUERY + "') = 1";
+ MultiStageBrokerRequestHandler handler = newHandler(new
AllowAllAccessControlFactory());
+
+ // The query arrived 4 seconds ago, so the subquery gets at most the 6
seconds left
+ RequestContext requestContext = new DefaultRequestContext();
+ requestContext.setRequestArrivalTimeMillis(System.currentTimeMillis() -
4_000);
+ handler.handleRequestThrowing(REQUEST_ID, sql,
RequestUtils.parseQuery(sql), null, requestContext, null);
+ long timeoutMs =
Long.parseLong(getSubqueryRequest()._options.get(QueryOptionKey.TIMEOUT_MS));
+ assertTrue(timeoutMs > 0 && timeoutMs <= 6_000, "Timeout: " + timeoutMs);
+
+ // A shorter timeout of the subquery wins
+ _requests.clear();
+ String subquery = "SET timeoutMs = 100; " + SUBQUERY;
+ _subqueryResponses.put(subquery, idSetResponse(ID_SET));
+ String shorterTimeoutSql = "SELECT COUNT(*) FROM a WHERE IN_SUBQUERY(col1,
'" + subquery + "') = 1";
+ handleRequest(handler, shorterTimeoutSql,
RequestUtils.parseQuery(shorterTimeoutSql));
+ assertEquals(getSubqueryRequest()._options.get(QueryOptionKey.TIMEOUT_MS),
"100");
+
+ // No time left
+ _requests.clear();
+ requestContext.setRequestArrivalTimeMillis(System.currentTimeMillis() -
10_000);
+ QueryException exception = expectThrows(QueryException.class,
+ () -> handler.handleRequestThrowing(REQUEST_ID, sql,
RequestUtils.parseQuery(sql), null, requestContext,
+ null));
+ assertEquals(exception.getErrorCode(), QueryErrorCode.BROKER_TIMEOUT);
+ assertNoSubqueryRan();
+ }
+
+ @Test
+ public void testUsesTheEmptyIdSetForNoRowsOrNull()
+ throws Exception {
+ MultiStageBrokerRequestHandler handler = newHandler(new
AllowAllAccessControlFactory());
+ for (BrokerResponse response : List.of(response(stringColumn(), List.of()),
+ response(stringColumn(), List.<Object[]>of(new Object[]{null})))) {
+ _subqueryResponses.put(SUBQUERY, response);
+ String sql = "SELECT COUNT(*) FROM a WHERE IN_SUBQUERY(col1, '" +
SUBQUERY + "') = 0";
+ SqlNodeAndOptions sqlNodeAndOptions = RequestUtils.parseQuery(sql);
+ handleRequest(handler, sql, sqlNodeAndOptions);
+ String rewrittenQuery = sqlNodeAndOptions.getSqlNode().toString();
+ assertTrue(rewrittenQuery.contains("'" +
IdSetSubqueryRewriter.EMPTY_ID_SET + "'"), rewrittenQuery);
+ }
+ }
+
+ @Test
+ public void testFailsWhenTheSubqueryFails()
+ throws Exception {
+ MultiStageBrokerRequestHandler handler = newHandler(new
AllowAllAccessControlFactory());
+ String sql = "SELECT COUNT(*) FROM a WHERE IN_SUBQUERY(col1, '" + SUBQUERY
+ "') = 1";
+
+ BrokerResponseNativeV2 failedResponse = new BrokerResponseNativeV2();
+ failedResponse.addException(new
QueryProcessingException(QueryErrorCode.EXECUTION_TIMEOUT, "Too slow"));
+ _subqueryResponses.put(SUBQUERY, failedResponse);
+ assertFails(handler, sql, QueryErrorCode.EXECUTION_TIMEOUT, "Subquery
failed: " + SUBQUERY + ": Too slow");
+
+ _subqueryResponses.clear();
+ _subqueryErrors.put(SUBQUERY,
QueryErrorCode.QUERY_VALIDATION.asException("Unknown column"));
+ String message = assertFails(handler, sql,
QueryErrorCode.QUERY_VALIDATION, "Subquery failed: " + SUBQUERY);
+ assertTrue(message.contains("Unknown column"), message);
+ _subqueryErrors.clear();
+
+ // An IdSet built from part of the rows would make the query miss rows
+ BrokerResponseNativeV2 partialResponse = (BrokerResponseNativeV2)
idSetResponse(ID_SET);
+ partialResponse.mergeNumGroupsLimitReached(true);
+ _subqueryResponses.put(SUBQUERY, partialResponse);
+ assertFails(handler, sql, QueryErrorCode.QUERY_EXECUTION,
+ "Subquery returned a partial result [numGroupsLimitReached]: " +
SUBQUERY);
+ partialResponse = (BrokerResponseNativeV2) idSetResponse(ID_SET);
+ partialResponse.mergeMaxRowsInWindowReached(true);
+ _subqueryResponses.put(SUBQUERY, partialResponse);
+ assertFails(handler, sql, QueryErrorCode.QUERY_EXECUTION,
+ "Subquery returned a partial result [maxRowsInWindowReached]: " +
SUBQUERY);
+ // A partial result that no known flag explains, e.g. a flag added later
+ BrokerResponseNativeV2 unexplainedPartialResponse = new
BrokerResponseNativeV2() {
+ @Override
+ public boolean isPartialResult() {
+ return true;
+ }
+ };
+ unexplainedPartialResponse.setResultTable(new ResultTable(stringColumn(),
List.<Object[]>of(new Object[]{ID_SET})));
+ _subqueryResponses.put(SUBQUERY, unexplainedPartialResponse);
+ assertFails(handler, sql, QueryErrorCode.QUERY_EXECUTION, "Subquery
returned a partial result: " + SUBQUERY);
+
+ _subqueryResponses.put(SUBQUERY, response(new DataSchema(new String[]{"a",
"b"},
+ new ColumnDataType[]{ColumnDataType.STRING, ColumnDataType.STRING}),
List.of()));
+ assertFails(handler, sql, QueryErrorCode.QUERY_VALIDATION, "Subquery must
return one STRING column");
+
+ _subqueryResponses.put(SUBQUERY,
+ response(new DataSchema(new String[]{"a"}, new
ColumnDataType[]{ColumnDataType.LONG}), List.of()));
+ assertFails(handler, sql, QueryErrorCode.QUERY_VALIDATION, "Subquery must
return one STRING column");
+
+ _subqueryResponses.put(SUBQUERY,
+ response(stringColumn(), List.of(new Object[]{ID_SET}, new
Object[]{ID_SET})));
+ assertFails(handler, sql, QueryErrorCode.QUERY_EXECUTION, "Subquery must
return at most one row, got: 2");
+ }
+
+ @Test
+ public void testFailsOnSubqueriesThatAreNotQueriesOrSetRejectedOptions()
+ throws Exception {
+ MultiStageBrokerRequestHandler handler = newHandler(new
AllowAllAccessControlFactory());
+ assertFails(handler, "SELECT COUNT(*) FROM a WHERE IN_SUBQUERY(col1,
'EXPLAIN PLAN FOR " + SUBQUERY + "') = 1",
+ QueryErrorCode.QUERY_VALIDATION, "Subquery is not a query");
+ assertFails(handler, "SELECT COUNT(*) FROM a WHERE IN_SUBQUERY(col1,
'SELECT FROM') = 1",
+ QueryErrorCode.SQL_PARSING, "Failed to parse subquery");
+
+ // The request does not allow options in the SQL, so the subquery cannot
set any either
+ String sql = "SELECT COUNT(*) FROM a WHERE IN_SUBQUERY(col1, 'SET
numGroupsLimit = 8; " + SUBQUERY + "') = 1";
+ SqlNodeAndOptions sqlNodeAndOptions = RequestUtils.parseQuery(sql);
+ sqlNodeAndOptions.getOptions().put(QueryOptionKey.SQL_OPTIONS_MODE,
"REJECT");
+ QueryException exception = expectThrows(QueryException.class,
+ () -> handleRequest(handler, sql, sqlNodeAndOptions));
+ assertEquals(exception.getErrorCode(), QueryErrorCode.QUERY_VALIDATION);
+ assertTrue(exception.getMessage().contains("Query options are not allowed
in the SQL"), exception.getMessage());
+ assertNoSubqueryRan();
+ }
+
+ @Test
+ public void testChecksAccessToTheTablesOfTheSubquery()
+ throws Exception {
+ // Denies table b, which only the subquery reads. The test does not answer
the subquery, so it compiles and gets
+ // checked like any other query.
+ AccessControl denyB = new AccessControl() {
+ @Override
+ public TableAuthorizationResult authorize(RequesterIdentity
requesterIdentity, Set<String> tables) {
+ Set<String> failedTables = tables.stream().filter(table ->
table.startsWith("b")).collect(Collectors.toSet());
+ return failedTables.isEmpty() ? TableAuthorizationResult.success() :
new TableAuthorizationResult(failedTables);
+ }
+ };
+ MultiStageBrokerRequestHandler handler = newHandler(new
AccessControlFactory() {
+ @Override
+ public AccessControl create() {
+ return denyB;
+ }
+ });
+ String sql = "SELECT COUNT(*) FROM a WHERE IN_SUBQUERY(col1, '" + SUBQUERY
+ "') = 1";
+ WebApplicationException exception =
expectThrows(WebApplicationException.class,
+ () -> handleRequest(handler, sql, RequestUtils.parseQuery(sql)));
+ assertEquals(exception.getResponse().getStatus(), 403);
+ }
+
+ @Test
+ public void testExplainRunsNoSubqueries()
+ throws Exception {
+ MultiStageBrokerRequestHandler handler = newHandler(new
AllowAllAccessControlFactory());
+ _subqueryResponses.put(SUBQUERY, idSetResponse(ID_SET));
+ _subqueryResponses.put(SCALAR_SUBQUERY, idSetResponse(ID_SET));
+ String sql = "EXPLAIN PLAN FOR SELECT COUNT(*) FROM a WHERE
IN_SUBQUERY(col1, '" + SUBQUERY + "') = 1 "
+ + "AND IN_ID_SET(col2, " + SCALAR_SUBQUERY + ") = 1";
+ BrokerResponse response = handleRequest(handler, sql,
RequestUtils.parseQuery(sql));
+ assertNoSubqueryRan();
+ assertTrue(response.getExceptions().isEmpty(),
response.getExceptions().toString());
+ ResultTable resultTable = response.getResultTable();
+ List<String> columnNames =
List.of(resultTable.getDataSchema().getColumnNames());
+ assertEquals(columnNames.get(columnNames.size() - 1),
"ID_SET_SUBQUERIES_NOT_RUN");
+ Object[] row = resultTable.getRows().get(0);
+ String plan = (String) row[1];
+ assertTrue(plan.contains("'" + IdSetSubqueryRewriter.EMPTY_ID_SET + "'"),
plan);
+ assertFalse(plan.contains("Join"), plan);
+ assertEquals(row[columnNames.size() - 1], "[\"" + SUBQUERY + "\",\"" +
SCALAR_SUBQUERY + "\"]");
+
+ // Without IdSet subqueries, the response has no extra column
+ String plainSql = "EXPLAIN PLAN FOR SELECT COUNT(*) FROM a";
+ response = handleRequest(handler, plainSql,
RequestUtils.parseQuery(plainSql));
+
assertFalse(List.of(response.getResultTable().getDataSchema().getColumnNames())
+ .contains("ID_SET_SUBQUERIES_NOT_RUN"));
+ }
+
+ @Test
+ public void testRunsUncorrelatedScalarSubqueriesFirst()
+ throws Exception {
+ MultiStageBrokerRequestHandler handler = newHandler(new
AllowAllAccessControlFactory());
+ _subqueryResponses.put(SCALAR_SUBQUERY, idSetResponse(ID_SET));
+ String sql = "SELECT COUNT(*) FROM a WHERE IN_ID_SET(col1, " +
SCALAR_SUBQUERY + ") = 1";
+ SqlNodeAndOptions sqlNodeAndOptions = RequestUtils.parseQuery(sql);
+ handleRequest(handler, sql, sqlNodeAndOptions);
+ assertEquals(getSubqueryRequest()._query, SCALAR_SUBQUERY);
+ assertTrue(sqlNodeAndOptions.getSqlNode().toString().contains(ID_SET));
+
+ // A correlated subquery does not validate on its own, so it runs as part
of the query
+ _requests.clear();
+ String correlatedSubquery = "(SELECT IDSET(b.col1) FROM b WHERE b.col2 =
a.col2)";
+ _subqueryResponses.put(correlatedSubquery, idSetResponse(ID_SET));
+ String correlatedSql = "SELECT COUNT(*) FROM a WHERE IN_ID_SET(a.col1, " +
correlatedSubquery + ") = 1";
+ handleRequest(handler, correlatedSql,
RequestUtils.parseQuery(correlatedSql));
+ assertNoSubqueryRan();
+
+ // An option that fails the subquery's validation also fails the query,
which compiles the subquery in its plan
+ _requests.clear();
+ String invalidOptionSql = "SET inferPartitionHint = 'foo'; " + sql;
+ QueryException exception = expectThrows(QueryException.class,
+ () -> handleRequest(handler, invalidOptionSql,
RequestUtils.parseQuery(invalidOptionSql)));
+ assertTrue(exception.getMessage().contains("inferPartitionHint"),
exception.getMessage());
+ assertNoSubqueryRan();
+
+ // On its own, the subquery would read table b instead of the WITH item b
+ _requests.clear();
+ String withSql = "WITH b AS (SELECT col1 FROM c WHERE col2 = 'foo') "
+ + "SELECT COUNT(*) FROM a WHERE IN_ID_SET(col1, " + SCALAR_SUBQUERY +
") = 1";
+ handleRequest(handler, withSql, RequestUtils.parseQuery(withSql));
+ assertNoSubqueryRan();
+ }
+
+ /// Checks that the query fails with the error code and a message that
contains the given one. Returns the message.
+ private String assertFails(MultiStageBrokerRequestHandler handler, String
sql, QueryErrorCode errorCode,
+ String message) {
+ QueryException exception =
+ expectThrows(QueryException.class, () -> handleRequest(handler, sql,
RequestUtils.parseQuery(sql)));
+ assertEquals(exception.getErrorCode(), errorCode);
+ assertTrue(exception.getMessage().contains(message),
exception.getMessage());
+ return exception.getMessage();
+ }
+
+ private BrokerResponse handleRequest(MultiStageBrokerRequestHandler handler,
String sql,
+ SqlNodeAndOptions sqlNodeAndOptions) {
+ RequestContext requestContext = new DefaultRequestContext();
+ requestContext.setRequestArrivalTimeMillis(System.currentTimeMillis());
+ return handler.handleRequestThrowing(REQUEST_ID, sql, sqlNodeAndOptions,
_requesterIdentity, requestContext,
+ _httpHeaders);
+ }
+
+ /// Returns the only subquery request, i.e. the only request other than the
query of the test.
+ private Request getSubqueryRequest() {
+ List<Request> subqueryRequests = getSubqueryRequests();
+ assertEquals(subqueryRequests.size(), 1, subqueryRequests.toString());
+ return subqueryRequests.get(0);
+ }
+
+ private void assertNoSubqueryRan() {
+ assertEquals(getSubqueryRequests(), List.of());
+ }
+
+ private List<Request> getSubqueryRequests() {
+ return _requests.stream().filter(request -> request._requestId !=
REQUEST_ID).collect(Collectors.toList());
+ }
+
+ private static BrokerResponse idSetResponse(String idSet) {
+ return response(stringColumn(), List.<Object[]>of(new Object[]{idSet}));
+ }
+
+ private static DataSchema stringColumn() {
+ return new DataSchema(new String[]{"idset(col1)"}, new
ColumnDataType[]{ColumnDataType.STRING});
+ }
+
+ private static BrokerResponse response(DataSchema dataSchema, List<Object[]>
rows) {
+ BrokerResponseNativeV2 response = new BrokerResponseNativeV2();
+ response.setResultTable(new ResultTable(dataSchema, rows));
+ return response;
+ }
+
+ private MultiStageBrokerRequestHandler newHandler(AccessControlFactory
accessControlFactory)
+ throws IOException {
+ int port1 = NetUtils.findOpenPort();
+ int port2 = NetUtils.findOpenPort();
+ MockRoutingManagerFactory factory = new MockRoutingManagerFactory(port1,
port2);
+ QueryEnvironmentTestBase.TABLE_SCHEMAS.forEach((tableName, schema) ->
factory.registerTable(schema, tableName));
+ QueryEnvironmentTestBase.SERVER1_SEGMENTS.forEach(
+ (tableName, segments) -> segments.forEach(segment ->
factory.registerSegment(port1, tableName, segment)));
+ QueryEnvironmentTestBase.SERVER2_SEGMENTS.forEach(
+ (tableName, segments) -> segments.forEach(segment ->
factory.registerSegment(port2, tableName, segment)));
+ RoutingManager routingManager = factory.buildRoutingManager(null);
+ WorkerManager workerManager =
+ new WorkerManager("Broker_localhost", "localhost",
NetUtils.findOpenPort(), routingManager);
+
+ PinotConfiguration config = new PinotConfiguration();
+ config.setProperty(MultiStageQueryRunner.KEY_OF_QUERY_RUNNER_HOSTNAME,
"localhost");
+ config.setProperty(MultiStageQueryRunner.KEY_OF_QUERY_RUNNER_PORT,
Integer.toString(NetUtils.findOpenPort()));
+ BrokerQueryEventListenerFactory.init(config);
+ BrokerMetrics.register(mock(BrokerMetrics.class));
+ QueryQuotaManager queryQuotaManager = mock(QueryQuotaManager.class);
+ when(queryQuotaManager.acquire(anyString())).thenReturn(true);
+ when(queryQuotaManager.acquireDatabase(anyString())).thenReturn(true);
+
+ return new MultiStageBrokerRequestHandler(config, "testBrokerId", new
BrokerRequestIdGenerator(), routingManager,
+ accessControlFactory, queryQuotaManager, factory.buildTableCache(),
mock(MultiStageQueryThrottler.class),
+ mock(FailureDetector.class),
ThreadAccountantUtils.getNoOpAccountant(), null, workerManager, workerManager) {
+ @Override
+ public void start() {
+ }
+
+ @Override
+ public void shutDown() {
+ }
+
+ @Override
+ protected BrokerResponse handleRequestThrowing(long requestId, String
query,
+ SqlNodeAndOptions sqlNodeAndOptions, @Nullable RequesterIdentity
requesterIdentity,
+ RequestContext requestContext, @Nullable HttpHeaders httpHeaders) {
+ _requests.add(new Request(requestId, query, new
HashMap<>(sqlNodeAndOptions.getOptions()), requestContext,
+ requesterIdentity, httpHeaders));
+ QueryException error = _subqueryErrors.get(query);
+ if (error != null) {
+ throw error;
+ }
+ BrokerResponse response = _subqueryResponses.get(query);
+ return response != null
+ ? response
+ : super.handleRequestThrowing(requestId, query, sqlNodeAndOptions,
requesterIdentity, requestContext,
+ httpHeaders);
+ }
+
+ @Override
+ protected void onQueryCompletion(RequestContext requestContext,
BrokerResponse brokerResponse) {
+ _completedRequests.add(requestContext);
+ }
+ };
+ }
+
+ private static class Request {
+ final long _requestId;
+ final String _query;
+ final Map<String, String> _options;
+ final RequestContext _requestContext;
+ final RequesterIdentity _requesterIdentity;
+ final HttpHeaders _httpHeaders;
+
+ Request(long requestId, String query, Map<String, String> options,
RequestContext requestContext,
+ @Nullable RequesterIdentity requesterIdentity, @Nullable HttpHeaders
httpHeaders) {
+ _requestId = requestId;
+ _query = query;
+ _options = options;
+ _requestContext = requestContext;
+ _requesterIdentity = requesterIdentity;
+ _httpHeaders = httpHeaders;
+ }
+
+ @Override
+ public String toString() {
+ return _requestId + ": " + _query;
+ }
+ }
+}
diff --git
a/pinot-common/src/main/java/org/apache/pinot/common/response/broker/BrokerResponseNativeV2.java
b/pinot-common/src/main/java/org/apache/pinot/common/response/broker/BrokerResponseNativeV2.java
index d4b2152d4be..827c800d037 100644
---
a/pinot-common/src/main/java/org/apache/pinot/common/response/broker/BrokerResponseNativeV2.java
+++
b/pinot-common/src/main/java/org/apache/pinot/common/response/broker/BrokerResponseNativeV2.java
@@ -124,7 +124,7 @@ public class BrokerResponseNativeV2 implements
BrokerResponse {
@Override
public boolean isPartialResult() {
return getExceptionsSize() > 0 || isNumGroupsLimitReached() ||
!getEarlyTerminationReasons().isEmpty()
- || isMaxRowsInJoinReached() || isMseLiteLeafStageLimitReached();
+ || isMaxRowsInJoinReached() || isMaxRowsInWindowReached() ||
isMseLiteLeafStageLimitReached();
}
@Override
diff --git
a/pinot-common/src/main/java/org/apache/pinot/common/utils/request/RequestUtils.java
b/pinot-common/src/main/java/org/apache/pinot/common/utils/request/RequestUtils.java
index a1bf57f25a1..0f913137795 100644
---
a/pinot-common/src/main/java/org/apache/pinot/common/utils/request/RequestUtils.java
+++
b/pinot-common/src/main/java/org/apache/pinot/common/utils/request/RequestUtils.java
@@ -118,7 +118,13 @@ public class RequestUtils {
return;
}
LOGGER.debug("Query options are set to: {}", requestOptions);
- requestOptions =
QueryOptionsUtils.resolveCaseInsensitiveOptions(requestOptions);
+ setOptions(sqlNodeAndOptions,
QueryOptionsUtils.resolveCaseInsensitiveOptions(requestOptions));
+ }
+
+ /// Merges the given options into the options parsed from the SQL, like
[#setOptions(SqlNodeAndOptions, JsonNode)]
+ /// does with the options of the request payload. The keys of the given
options must be resolved, see
+ /// [QueryOptionsUtils#resolveCaseInsensitiveOptions(Map)].
+ public static void setOptions(SqlNodeAndOptions sqlNodeAndOptions,
Map<String, String> requestOptions) {
SqlOptionsMode sqlOptionsMode =
QueryOptionsUtils.getSqlOptionsMode(requestOptions);
Map<String, String> sqlOptions = sqlNodeAndOptions.getOptions();
if (sqlOptionsMode != SqlOptionsMode.ALLOW && !sqlOptions.isEmpty()) {
diff --git
a/pinot-common/src/test/java/org/apache/pinot/common/response/broker/BrokerResponseNativeV2Test.java
b/pinot-common/src/test/java/org/apache/pinot/common/response/broker/BrokerResponseNativeV2Test.java
index 139fa96aa76..3a8d1aa4a88 100644
---
a/pinot-common/src/test/java/org/apache/pinot/common/response/broker/BrokerResponseNativeV2Test.java
+++
b/pinot-common/src/test/java/org/apache/pinot/common/response/broker/BrokerResponseNativeV2Test.java
@@ -82,6 +82,21 @@ public class BrokerResponseNativeV2Test {
assertEquals(json.get("directRemoteAccessBytes").asLong(), 20L);
}
+ @Test
+ public void testRowLimitsMarkPartialResponse()
+ throws Exception {
+ // A join or window stops at its row limit only in overflow mode BREAK,
and then returns part of its result
+ BrokerResponseNativeV2 joinResponse = new BrokerResponseNativeV2();
+ joinResponse.mergeMaxRowsInJoinReached(true);
+ assertTrue(joinResponse.isPartialResult());
+
+ BrokerResponseNativeV2 windowResponse = new BrokerResponseNativeV2();
+ assertFalse(windowResponse.isPartialResult());
+ windowResponse.mergeMaxRowsInWindowReached(true);
+ assertTrue(windowResponse.isPartialResult());
+
assertTrue(JsonUtils.objectToJsonNode(windowResponse).get("partialResult").asBoolean());
+ }
+
@Test
public void testResponseMetadataSerialization()
throws Exception {
diff --git
a/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/BaseClusterIntegrationTestSet.java
b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/BaseClusterIntegrationTestSet.java
index d13a9723214..9f4c0e4b31d 100644
---
a/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/BaseClusterIntegrationTestSet.java
+++
b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/BaseClusterIntegrationTestSet.java
@@ -107,10 +107,9 @@ public abstract class BaseClusterIntegrationTestSet
extends BaseClusterIntegrati
public void testHardcodedQueries()
throws Exception {
testHardcodedQueriesCommon();
+ testHardcodedIdSetQueries();
if (useMultiStageQueryEngine()) {
testHardcodedQueriesV2();
- } else {
- testHardCodedQueriesV1();
}
}
@@ -361,9 +360,9 @@ public abstract class BaseClusterIntegrationTestSet extends
BaseClusterIntegrati
testQuery(query, h2Query);
}
- private void testHardCodedQueriesV1()
+ /// Tests IN_ID_SET and IN_SUBQUERY, which are not standard SQL.
+ private void testHardcodedIdSetQueries()
throws Exception {
- // Non-Standard SQL syntax:
// IN_ID_SET
{
IdSet idSet = IdSets.create(FieldSpec.DataType.LONG);
diff --git
a/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/JmxMetricsIntegrationTest.java
b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/JmxMetricsIntegrationTest.java
index f96e42f298d..53d0cc96f5f 100644
---
a/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/JmxMetricsIntegrationTest.java
+++
b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/JmxMetricsIntegrationTest.java
@@ -110,15 +110,15 @@ public class JmxMetricsIntegrationTest extends
BaseClusterIntegrationTestSet {
postQuery("SELECT COUNT(*) FROM mytable");
+ // Works with the multi-stage query engine too. The subquery counts as a
query of its own.
+ JsonNode response = postQuery("SELECT AirlineID, count(*) FROM mytable
WHERE IN_SUBQUERY(airlineID, 'SELECT "
+ + "ID_SET(AirlineID) FROM mytable WHERE Carrier = ''AA''') = 1 GROUP
BY AirlineID;");
+ assertFalse(response.get("resultTable").get("rows").isEmpty());
+
// Run some queries that are known to not work as is with the multi-stage
query engine
// MV column requires ARRAY_TO_MV wrapper to be used in filter predicates
- JsonNode response = postQuery("SELECT COUNT(*) FROM mytable WHERE
DivAirports = 'JFK'");
- assertFalse(response.get("resultTable").get("rows").isEmpty());
-
- // Unsupported function
- response = postQuery("SELECT AirlineID, count(*) FROM mytable WHERE
IN_SUBQUERY(airlineID, 'SELECT "
- + "ID_SET(AirlineID) FROM mytable WHERE Carrier = ''AA''') = 1 GROUP
BY AirlineID;");
+ response = postQuery("SELECT COUNT(*) FROM mytable WHERE DivAirports =
'JFK'");
assertFalse(response.get("resultTable").get("rows").isEmpty());
// Repeated columns in an ORDER BY query
@@ -131,14 +131,14 @@ public class JmxMetricsIntegrationTest extends
BaseClusterIntegrationTestSet {
TestUtils.waitForCondition((aVoid) -> {
try {
multiStageMigrationMetricValue.set((Long)
MBEAN_SERVER.getAttribute(multiStageMigrationMetric, "Count"));
- return multiStageMigrationMetricValue.get() == 3L;
+ return multiStageMigrationMetricValue.get() == 2L;
} catch (Exception e) {
throw new RuntimeException(e);
}
}, 5000, "Expected value of MBean
'pinot.broker.singleStageQueriesInvalidMultiStage' to be: "
- + 3L + "; actual value: " + multiStageMigrationMetricValue.get());
+ + 2L + "; actual value: " + multiStageMigrationMetricValue.get());
- assertEquals((Long) MBEAN_SERVER.getAttribute(multiStageMigrationMetric,
"Count"), 3L);
+ assertEquals((Long) MBEAN_SERVER.getAttribute(multiStageMigrationMetric,
"Count"), 2L);
}
@Test(dataProvider = "useBothQueryEngines")
diff --git
a/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/custom/IdSetIntegrationTest.java
b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/custom/IdSetIntegrationTest.java
index e10ae92a223..c4697d74a08 100644
---
a/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/custom/IdSetIntegrationTest.java
+++
b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/custom/IdSetIntegrationTest.java
@@ -32,6 +32,7 @@ import org.apache.pinot.spi.data.Schema;
import org.testng.annotations.Test;
import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertFalse;
import static org.testng.Assert.assertTrue;
@@ -55,6 +56,10 @@ public class IdSetIntegrationTest extends
CustomDataQueryClusterIntegrationTest
private static final String BLOOM_FILTER_PARAMS =
"expectedInsertions=1000;fpp=0.0001";
// A Bloom filter sized for 10M ids, which serializes to a literal of about
16 MB
private static final String LARGE_BLOOM_FILTER_PARAMS =
"expectedInsertions=10000000;fpp=0.01";
+ // A Bloom filter sized for 20M ids, which serializes to a literal of about
32 MB
+ private static final String HUGE_BLOOM_FILTER_PARAMS =
"expectedInsertions=20000000;fpp=0.01";
+ // The number of rows whose value is in the sets built from the rows with id
< NUM_SET_VALUES, without false positives
+ private static final long NUM_ROWS_IN_SET = (long) NUM_SET_VALUES *
(NUM_ROWS / NUM_VALUES);
@Override
protected long getCountStarResult() {
@@ -99,15 +104,93 @@ public class IdSetIntegrationTest extends
CustomDataQueryClusterIntegrationTest
@Test
public void testScalarSubquery()
throws Exception {
- // The set argument is a scalar subquery, so IN_ID_SET runs as a scalar
function above the join that supplies it
+ // The subquery is not correlated, so it runs before the query, like the
subquery of an IN_SUBQUERY
setUseMultiStageQueryEngine(true);
for (String column : List.of(INT_COL, LONG_COL, STR_COL, BYTES_COL)) {
String idSetQuery = "SELECT " + idSetExpression(column) + " FROM " +
getTableName() + " WHERE " + SET_FILTER;
- String query =
- "SELECT COUNT(*) FROM " + getTableName() + " WHERE IN_ID_SET(" +
column + ", (" + idSetQuery + ")) = 1";
IdSet idSet =
IdSets.fromBase64String(getSingleValue(postQuery(idSetQuery)).asText());
- assertEquals(getSingleValue(postQuery(query)).asLong(),
getExpectedCount(column, idSet), column);
+ assertEquals(getCount("IN_ID_SET(" + column + ", (" + idSetQuery + ")) =
1"), getExpectedCount(column, idSet),
+ column);
}
+
+ // So the filter runs in the leaf stage, without a join. EXPLAIN does not
run the subquery, and says so.
+ JsonNode response = postQuery("EXPLAIN PLAN FOR SELECT COUNT(*) FROM " +
getTableName() + " WHERE IN_ID_SET("
+ + INT_COL + ", (SELECT IDSET(" + INT_COL + ") FROM " + getTableName()
+ " WHERE " + SET_FILTER + ")) = 1");
+ assertTrue(response.get("exceptions").isEmpty(),
response.get("exceptions").toString());
+ JsonNode resultTable = response.get("resultTable");
+ String plan = resultTable.get("rows").get(0).get(1).asText();
+ assertFalse(plan.contains("Join"), plan);
+
assertTrue(resultTable.get("dataSchema").get("columnNames").toString().contains("SUBQUERIES"),
+ resultTable.toString());
+
+ // A correlated subquery runs as part of the query
+ assertEquals(getSingleValue(postQuery("SELECT COUNT(*) FROM " +
getTableName() + " t1 WHERE IN_ID_SET(t1."
+ + INT_COL + ", (SELECT IDSET(t2." + INT_COL + ") FROM " +
getTableName() + " t2 WHERE t2." + SET_FILTER
+ + " AND t2." + LONG_COL + " = t1." + LONG_COL + ")) = 1")).asLong(),
NUM_ROWS_IN_SET);
+ }
+
+ @Test(dataProvider = "useBothQueryEngines")
+ public void testInSubquery(boolean useMultiStageQueryEngine)
+ throws Exception {
+ setUseMultiStageQueryEngine(useMultiStageQueryEngine);
+ for (String column : List.of(INT_COL, LONG_COL, STR_COL, BYTES_COL)) {
+ String idSetQuery = "SELECT " + idSetExpression(column) + " FROM " +
getTableName() + " WHERE " + SET_FILTER;
+ long expectedCount =
+ getExpectedCount(column,
IdSets.fromBase64String(getSingleValue(postQuery(idSetQuery)).asText()));
+ String inSubquery = "IN_SUBQUERY(" + column + ", " + quote(idSetQuery) +
")";
+ assertEquals(getCount(inSubquery + " = 1"), expectedCount, column);
+ assertEquals(getCount(inSubquery + " = 0"), NUM_ROWS - expectedCount,
column);
+ }
+ }
+
+ @Test
+ public void testInSubqueryOutsideFilters()
+ throws Exception {
+ // Only the multi-stage engine runs IN_SUBQUERY outside the WHERE clause
+ setUseMultiStageQueryEngine(true);
+ String subquery = quote("SELECT IDSET(" + INT_COL + ") FROM " +
getTableName() + " WHERE " + SET_FILTER);
+ String inSubquery = "IN_SUBQUERY(" + INT_COL + ", " + subquery + ") = 1";
+ assertEquals(getSingleValue(postQuery(
+ "SELECT SUM(CASE WHEN " + inSubquery + " THEN 1 ELSE 0 END) FROM "
+ getTableName())).asLong(),
+ NUM_ROWS_IN_SET);
+ JsonNode response = postQuery("SELECT " + INT_COL + ", COUNT(*) FROM " +
getTableName() + " GROUP BY " + INT_COL
+ + " HAVING " + inSubquery + " LIMIT " + NUM_VALUES);
+ assertTrue(response.get("exceptions").isEmpty(),
response.get("exceptions").toString());
+ assertEquals(response.get("resultTable").get("rows").size(),
NUM_SET_VALUES);
+ assertEquals(getSingleValue(postQuery("SELECT COUNT(*) FROM " +
getTableName() + " t1 JOIN " + getTableName()
+ + " t2 ON t1." + ID + " = t2." + ID + " WHERE IN_SUBQUERY(t2." +
INT_COL + ", " + subquery + ") = 1"))
+ .asLong(), NUM_ROWS_IN_SET);
+
+ // A subquery can have IN_SUBQUERY too
+ String nestedInSubquery = "IN_SUBQUERY(" + ID + ", " + quote(
+ "SELECT IDSET(" + ID + ") FROM " + getTableName() + " WHERE " +
SET_FILTER) + ") = 1";
+ assertEquals(getCount("IN_SUBQUERY(" + INT_COL + ", " + quote(
+ "SELECT IDSET(" + INT_COL + ") FROM " + getTableName() + " WHERE " +
nestedInSubquery) + ") = 1"),
+ NUM_ROWS_IN_SET);
+ }
+
+ @Test
+ public void testFailsOnPartialSubqueryResult()
+ throws Exception {
+ // The subquery stops at 10 groups, so its IdSet would miss ids
+ setUseMultiStageQueryEngine(true);
+ String subquery = "SET numGroupsLimit = 10; SELECT IDSET(" + INT_COL + ")
FROM (SELECT " + INT_COL + " FROM "
+ + getTableName() + " GROUP BY " + INT_COL + ")";
+ JsonNode response = postQuery("SELECT COUNT(*) FROM " + getTableName() + "
WHERE IN_SUBQUERY(" + INT_COL + ", "
+ + quote(subquery) + ") = 1");
+ String exceptions = response.get("exceptions").toString();
+ assertTrue(exceptions.contains("Subquery returned a partial result
[numGroupsLimitReached]"), exceptions);
+ }
+
+ @Test
+ public void testHugeIdSetInSubquery()
+ throws Exception {
+ // The set never reaches the client or the parser, so it can be larger
than the 20M chars a JSON request can hold
+ setUseMultiStageQueryEngine(true);
+ String idSetQuery = "SELECT IDSET(" + STR_COL + ", '" +
HUGE_BLOOM_FILTER_PARAMS + "') FROM " + getTableName()
+ + " WHERE " + SET_FILTER;
+ // With 60 of the 20M ids it is sized for, the Bloom filter has
practically no false positives
+ assertEquals(getCount("IN_SUBQUERY(" + STR_COL + ", " + quote(idSetQuery)
+ ") = 1"), NUM_ROWS_IN_SET);
}
@Test(dataProvider = "useBothQueryEngines")
@@ -181,6 +264,17 @@ public class IdSetIntegrationTest extends
CustomDataQueryClusterIntegrationTest
return "value_" + value;
}
+ /// Returns the SQL string literal for the given string.
+ private static String quote(String string) {
+ return "'" + string.replace("'", "''") + "'";
+ }
+
+ /// Returns the number of rows that pass the filter.
+ private long getCount(String filter)
+ throws Exception {
+ return getSingleValue(postQuery("SELECT COUNT(*) FROM " + getTableName() +
" WHERE " + filter)).asLong();
+ }
+
private static JsonNode getSingleValue(JsonNode response) {
assertTrue(response.get("exceptions").isEmpty(),
response.get("exceptions").toString());
return response.get("resultTable").get("rows").get(0).get(0);
diff --git
a/pinot-query-planner/src/main/java/org/apache/pinot/calcite/sql/fun/PinotOperatorTable.java
b/pinot-query-planner/src/main/java/org/apache/pinot/calcite/sql/fun/PinotOperatorTable.java
index e05ee827547..c6750013e74 100644
---
a/pinot-query-planner/src/main/java/org/apache/pinot/calcite/sql/fun/PinotOperatorTable.java
+++
b/pinot-query-planner/src/main/java/org/apache/pinot/calcite/sql/fun/PinotOperatorTable.java
@@ -299,6 +299,12 @@ public class PinotOperatorTable implements
SqlOperatorTable {
OperandTypes.family(
List.of(SqlTypeFamily.CHARACTER, SqlTypeFamily.CHARACTER,
SqlTypeFamily.INTEGER), i -> i == 2)),
+ // IN_SUBQUERY(expr, 'subquery') never runs: the broker runs the
subquery first and replaces the call with an
+ // IN_ID_SET on the result. It is registered for the paths that only
compile queries, such as the controller
+ // routing a query to a broker. Nullable like IN_ID_SET.
+ new PinotSqlFunction("IN_SUBQUERY", ReturnTypes.BOOLEAN_NULLABLE,
+ OperandTypes.family(SqlTypeFamily.ANY, SqlTypeFamily.CHARACTER)),
+
// Placeholder for special functions to handle MV
// NOTE:
// ARRAY_TO_MV is not deterministic.
diff --git
a/pinot-query-planner/src/main/java/org/apache/pinot/query/QueryEnvironment.java
b/pinot-query-planner/src/main/java/org/apache/pinot/query/QueryEnvironment.java
index 34cc9e7c92d..ce5d25c7558 100644
---
a/pinot-query-planner/src/main/java/org/apache/pinot/query/QueryEnvironment.java
+++
b/pinot-query-planner/src/main/java/org/apache/pinot/query/QueryEnvironment.java
@@ -347,6 +347,20 @@ public class QueryEnvironment {
}
}
+ /// Validates the query without converting or optimizing it, which costs
less than [#compile(String,
+ /// SqlNodeAndOptions)]. The validation changes the parsed query.
+ ///
+ /// @throws QueryException if the query does not validate, e.g. because of
an invalid query option
+ public void validate(SqlNodeAndOptions sqlNodeAndOptions) {
+ try (PlannerContext plannerContext = getPlannerContext(sqlNodeAndOptions))
{
+ validate(sqlNodeAndOptions.getSqlNode(), plannerContext);
+ } catch (QueryException e) {
+ throw e;
+ } catch (RuntimeException e) {
+ throw QueryErrorCode.QUERY_VALIDATION.asException(e.getMessage(), e);
+ }
+ }
+
/// Results of planning a query
public static class QueryPlannerResult {
private final DispatchableSubPlan _dispatchableSubPlan;
diff --git
a/pinot-query-planner/src/test/java/org/apache/pinot/query/QueryCompilationTest.java
b/pinot-query-planner/src/test/java/org/apache/pinot/query/QueryCompilationTest.java
index baa47e43635..c91508d5d41 100644
---
a/pinot-query-planner/src/test/java/org/apache/pinot/query/QueryCompilationTest.java
+++
b/pinot-query-planner/src/test/java/org/apache/pinot/query/QueryCompilationTest.java
@@ -47,6 +47,11 @@ import org.apache.pinot.query.planner.plannode.ProjectNode;
import org.apache.pinot.query.planner.plannode.SetOpNode;
import org.apache.pinot.query.planner.plannode.WindowNode;
import org.apache.pinot.query.routing.QueryServerInstance;
+import org.apache.pinot.spi.exception.QueryErrorCode;
+import org.apache.pinot.spi.exception.QueryException;
+import
org.apache.pinot.spi.utils.CommonConstants.Broker.Request.QueryOptionKey;
+import org.apache.pinot.sql.parsers.CalciteSqlParser;
+import org.apache.pinot.sql.parsers.SqlNodeAndOptions;
import org.testng.annotations.DataProvider;
import org.testng.annotations.Ignore;
import org.testng.annotations.Test;
@@ -1689,6 +1694,35 @@ public class QueryCompilationTest extends
QueryEnvironmentTestBase {
return false;
}
+ @Test
+ public void testCompilesInSubquery() {
+ // The broker runs the subquery before it compiles the query, but the
paths that only compile queries accept
+ // IN_SUBQUERY too. They see the tables outside the subquery only.
+ try (QueryEnvironment.CompiledQuery compiledQuery =
_queryEnvironment.compile(
+ "SELECT COUNT(*) FROM a WHERE IN_SUBQUERY(col1, 'SELECT IDSET(col1)
FROM b') = 1")) {
+ assertEquals(compiledQuery.getTableNames(), Set.of("a"));
+ }
+ assertTrue(_queryEnvironment.canCompileQuery(
+ "SELECT inSubquery(col3, 'SELECT IDSET(col3) FROM b') FROM a WHERE
INSUBQUERY(col2, 'SELECT 1') = 0"));
+ }
+
+ @Test
+ public void testValidate() {
+
_queryEnvironment.validate(CalciteSqlParser.compileToSqlNodeAndOptions("SELECT
col1 FROM a WHERE col3 > 1"));
+
+ // A correlated subquery does not validate on its own
+ expectThrows(QueryException.class, () -> _queryEnvironment.validate(
+ CalciteSqlParser.compileToSqlNodeAndOptions("SELECT IDSET(b.col1) FROM
b WHERE b.col2 = a.col2")));
+
+ // An invalid option fails before the validation starts, but is reported
the same way
+ SqlNodeAndOptions sqlNodeAndOptions =
CalciteSqlParser.compileToSqlNodeAndOptions("SELECT col1 FROM a");
+ sqlNodeAndOptions.getOptions().put(QueryOptionKey.INFER_PARTITION_HINT,
"foo");
+ QueryException exception =
+ expectThrows(QueryException.class, () ->
_queryEnvironment.validate(sqlNodeAndOptions));
+ assertEquals(exception.getErrorCode(), QueryErrorCode.QUERY_VALIDATION);
+
assertTrue(exception.getMessage().contains(QueryOptionKey.INFER_PARTITION_HINT),
exception.getMessage());
+ }
+
@DataProvider(name = "testQueryExceptionDataProvider")
private Object[][] provideQueriesWithException() {
return new Object[][]{
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]