This is an automated email from the ASF dual-hosted git repository.
FrankChen021 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/druid.git
The following commit(s) were added to refs/heads/master by this push:
new 2e521fd2d57 fix: descend all legs for requireTimeCondition (#17407)
(#20441)
2e521fd2d57 is described below
commit 2e521fd2d572fd4553ceee1d983137ee97b464c1
Author: NishthaShah <[email protected]>
AuthorDate: Wed Nov 4 07:39:38 2026 +0530
fix: descend all legs for requireTimeCondition (#17407) (#20441)
* fix: descend all legs for requireTimeCondition (#17407)
The requireTimeCondition guard has two defects in opposite directions:
* Pre-33: joins of two bounded subqueries were rejected because
DataSourceAnalysis.getBaseQuerySegmentSpec resolved the outer join
query first, whose interval is ETERNITY.
* v33+: non-collapsible subquery stacks are rejected because
ExecutionVertex.getEffectiveQuerySegmentSpec prunes at
Query.mayCollapseQueryDataSource (default false), so the inner
query is never visited.
Resolve the check outside ExecutionVertex with a walker that descends
every QueryDataSource and the left input of every JoinDataSource. The
right-hand input of a join is intentionally not followed (preserving
testRequireTimeConditionSemiJoinNegative), and the guard keys on query
intervals rather than filters (preserving rejection of outer __time
filters over unbounded subqueries).
Adds nine tests covering explicit joins, three-way joins, UNION ALL,
lookups on the right side of a join, non-collapsible subqueries, and
outer filters over unbounded subqueries.
* test: drop UNION ALL requireTimeCondition tests (orthogonal planner
limitations)
Both testRequireTimeConditionUnionAllBothBranchesFilteredPositive and
testRequireTimeConditionUnionAllOneBranchMissingFilterNegative fail
under MSQ and Decoupled planners for reasons unrelated to the
requireTimeCondition fix:
* MSQ rejects UNION ALL between filtered scans at planning time
('SQL requires union between inputs that are not simple table
scans') before NativeQueryMaker's guard runs.
* Decoupled produces a top-level UnionQuery (not a BaseQuery); the
~20 ExecutionVertex.of(query) callsites upstream of the analyzer
throw 'Can't traverse a query[UnionQuery]!'.
The base-planner UNION ALL cases pass and the analyzer handles the
tree correctly — supporting UNION ALL under MSQ/Decoupled would
require orthogonal fixes to ExecutionVertexShuttle and MSQ's union
planner, out of scope for this PR.
The remaining 14 requireTimeCondition tests still cover both regression
shapes from the RCA: non-collapsible subquery stacks (v33+) and joins
of two bounded subqueries (pre-33).
* test: add RequireTimeConditionAnalyzerTest for processing-module coverage
Seven direct-call tests exercise the analyzer's paths so the
diff-coverage check passes in the processing module (the sql-module
CalciteQueryTest coverage is not counted toward this file). Covers
bounded/unbounded scans, nested QueryDataSource descent (v33+
regression), global datasource short-circuit (LookupDataSource,
InlineDataSource), and JoinDataSource leg-boundary detection.
Coverage on RequireTimeConditionAnalyzer$TimeFilterExplorer:
98% instruction, 85% branch (was 0% / 0% from processing tests).
* test: cover bounded-outer-over-unbounded-inner in
RequireTimeConditionAnalyzer
Add two regression tests for the Codex P1 raised on #20441:
* testJoinWithLimitWrappedUnboundedRightIsNotSatisfied — a LIMIT-wrapped
QueryDataSource on the right leg of a join under a bounded outer scan;
LIMIT blocks __time predicate push-down, so the inner leaf stays at
ETERNITY. The walker must cross the join-leg boundary before reaching
the bounded ancestor and reject.
* testNestedJoinWithDeepUnboundedLegIsNotSatisfied — a nested
Join(FOO, Join(FOO, BAR)) under a bounded outer scan; the deepest leg
crosses two join-leg boundaries, exercising transitive descent.
Both guard against the pre-fix behavior where
ExecutionVertex.getEffectiveQuerySegmentSpec would return the outer
interval and silently accept the unbounded inner scan.
* fix: stop ancestor search at non-collapsible QueryDataSource boundary
Addresses Frank's follow-up P1 on #20441. The walker was returning true
for a bounded outer query over an unbounded inner QueryDataSource outside
any join-right leg, because the ancestor loop climbed past the inner
query (unbounded) and accepted the outer's bound as sufficient. The
physical table scan is actually owned by the inner query, so the outer's
bound does not reach the leaf unless the chain between leaf and outer
is fully collapsible.
Track collapsedChain while walking ancestors: for every Query past the
innermost, &= q.mayCollapseQueryDataSource(). Only accept a bounded
Query ancestor when the chain between the leaf and that ancestor has
collapsed. The innermost Query owns the leaf directly and does not need
a collapsibility check. Preserves existing behavior for
GroupBy-over-GroupBy (where mayCollapseQueryDataSource() is true) and
rejects Scan-over-Scan via QueryDataSource (where it is false).
Covered by new native test
RequireTimeConditionAnalyzerTest.testBoundedOuterOverUnboundedQueryDataSourceIsNotSatisfied.
Existing CalciteQueryTest#testRequireTimeCondition* cases unchanged.
* fix: require bound on innermost query owning each physical scan
mayCollapseQueryDataSource() is a datasource-collapse signal, not an
interval-propagation one. GroupByQuery returns true for it over a
QueryDataSource(GroupByQuery), but at runtime the inner query is executed
with its original intervals (see
GroupByQueryQueryToolChest#mergeGroupByResultsWithoutPushDown) and an
outer bound never reaches the physical scan.
Replace the ancestor-chain walk with the innermost-owning-query rule:
for each physical leaf, walk up to the first Query ancestor and check
its bound. Stop at join right-leg boundaries since the outer's spec
only applies to the primary (left) input.
Adds adversarial coverage for the follow-up P1 raised on #20441
(bounded outer GroupBy over ETERNITY inner GroupBy) plus five scenarios
locking down over-rejection and under-rejection edges: right leg with
its own bounded Query, 3-layer QDS with only middle bounded, bounded
inner under ETERNITY outer, and UnionDataSource with bounded/unbounded
outer.
* feat: report offending datasource and position in requireTimeCondition
error
Expose a Result record from RequireTimeConditionAnalyzer carrying the first
offending physical leaf's datasource name and its structural position
(TOP_LEVEL, NESTED_SUBQUERY, JOIN_RIGHT). NativeQueryMaker composes a
position-specific message so the user sees which table and roughly where
the __time filter is missing, instead of a single generic string.
* test: cover deep-nested subquery and join mixes in
RequireTimeConditionAnalyzer
Add four tests exercising the walker at 4-5 layers deep:
- Two pure-subquery depth-4 cases (bound at innermost / bound only in
middle)
confirm that only the innermost owning query's segment spec reaches the
physical scan.
- Two mixed subquery-and-join cases confirm the same at depth with a join
in the chain: three bounded subquery wrappers above a Join with a bare
unbounded right leg reject at the right-leg boundary (JOIN_RIGHT); a
five-layer mix with a bounded right-leg wrapper above an unbounded inner
rejects at the inner query (NESTED_SUBQUERY).
* test: cover nested-rollup positive case for requireTimeCondition SQL guard
Adds testRequireTimeConditionNestedRollupPositive to pin a common nested
GROUP BY shape at the SQL layer: an outer aggregate wrapping an inner
subquery whose GROUP BY owns the __time bound. The analyzer-level coverage
for this shape already exists via
testIntermediateBoundedInnerUnderUnboundedOuterIsSatisfied; this test adds
the end-to-end SQL assertion so the planner path stays regression-free.
* test: cover CTE queries for requireTimeCondition SQL guard
Adds two tests exercising the CTE (WITH ... AS (...)) surface:
- testRequireTimeConditionCtePositive: a CTE that owns the __time bound in
its
definition inlines to a bounded inner subquery under the outer aggregate;
the
guard must accept it.
- testRequireTimeConditionCteNegative: a CTE with no __time filter anywhere
inlines to an unbounded scan; the guard must reject with the "__time
column"
message.
Calcite inlines CTEs into equivalent nested subqueries (and unions when a
CTE
is referenced more than once), so these shapes already map onto existing
analyzer coverage. These tests pin the SQL-layer behaviour so WITH syntax is
not an unaudited user-visible dimension.
* test: parametrise requireTimeCondition positives over on/off planner
configs
Converts the nine testRequireTimeCondition*Positive* tests to JUnit 5
@ParameterizedTest, running each case under both
PLANNER_CONFIG_REQUIRE_TIME_CONDITION
and PLANNER_CONFIG_DEFAULT. Each test executes 2 invocations (18 total) and
asserts that enabling the guard does not change the native plan or the
result
rows for compliant queries.
Scope:
- testRequireTimeConditionPositive
- testRequireTimeConditionPositive2
- testRequireTimeConditionPositive3
- testRequireTimeConditionLogicalValuePositive
- testRequireTimeConditionExplicitJoinBothSidesFilteredPositive
- testRequireTimeConditionLookupRightSidePositive
- testRequireTimeConditionNonCollapsibleSubqueryPositive
- testRequireTimeConditionNestedRollupPositive
- testRequireTimeConditionCtePositive
Negatives remain plain @Test with assertThrows: under require-off they would
plan as normal unbounded queries, which already has coverage elsewhere and
offers weak incremental signal.
* test: keep testRequireTimeConditionPositive3 as plain @Test for quidem
recording
DecoupledPlanningCalciteQueryTest pins the native plan for this semi-join
via
a frozen quidem .iq file keyed on the test's display name. Parametrising the
method made the harness look for per-config recordings
([email protected] etc.) that don't exist,
breaking DecoupledPlanningCalciteQueryTest in CI.
Revert this one test to plain @Test. The guard runs post-plan, so the matrix
adds no planner coverage here anyway. The other 8 positives stay
parametrised.
* test: add quidem coverage for requireTimeCondition SQL guard
Pins the end-to-end behaviour of the guard through the SQL planner + Avatica
path on two positive shapes (bounded nested-rollup, bounded CTE) and one
negative (top-level unbounded) that asserts the formatted error message.
Complements the existing CalciteQueryTest coverage: those tests exercise the
guard against the native query tree directly, while this one validates the
plan + results + error-message flow a JDBC client actually sees. Also pins
the
mysql-formatted result tables, which protects against regressions in
planning
that would otherwise silently change what the guard sees under it.
* test: add embedded e2e coverage for requireTimeCondition broker property
Boots an in-process cluster with
druid.sql.planner.requireTimeCondition=true,
ingests a datasource via the standard BASIC_INDEX task, and submits SQL
through
the Broker's HTTP endpoint. Verifies both the positive path (bounded query
returns results) and the negative path (unbounded query is rejected via a
400 Bad Request carrying the "has no filter on the __time column" message).
This complements the existing coverage:
- RequireTimeConditionAnalyzerTest -- analyzer semantics (unit)
- CalciteQueryTest testRequireTimeCondition* -- planner-level SQL guard
(unit)
- requireTimeCondition.iq -- quidem SQL regression
recording
and closes the loop end-to-end through broker config wiring and the
Avatica/HTTP
response path.
* fix: narrow requireTimeCondition global-leaf exemption to non-segment
datasources
GlobalTableDataSource extends TableDataSource and remains segment-backed
when
queried directly or on a join's left leg (see its javadoc). The blanket
dataSource.isGlobal() exemption at the leaf check therefore let an unbounded
scan of a broadcast datasource bypass requireTimeCondition.
Restrict the leaf-level exemption to leaves that are not instanceof
TableDataSource (lookups, inline, frame-based inline), and preserve the
right-leg broadcast-join path by short-circuiting isGlobal() leaves inside
the
JOIN_RIGHT branch (where a GlobalTableDataSource is materialized via
JoinableFactory as an IndexedTable rather than scanned as segments).
Adds four analyzer-level regression tests covering direct (bounded +
eternity),
left-leg under an unbounded outer, and right-leg broadcast-join cases for
GlobalTableDataSource.
* docs: document stricter requireTimeCondition per-leaf enforcement
Expand the config reference and add release-note + upgrade-note entries
calling out that unbounded right-leg join inputs, semi-join subqueries,
and unbounded inner subqueries are now rejected even when the outer
query is bounded.
* docs: correct RequireTimeConditionAnalyzer javadoc on right-leg handling
The walker does descend into right-hand join inputs and rejects an
unbounded right-leg physical leaf with Position.JOIN_RIGHT, so the
earlier "intentionally not descended" wording was stale.
* test: enable requireTimeCondition via SQL query context in
EmbeddedRequireTimeConditionTest
Switch from configuring the broker-level
`druid.sql.planner.requireTimeCondition`
property to passing `requireTimeCondition=true` in the SQL query context so
the
test exercises the per-query enablement path (which takes precedence over
the
broker default).
* test: restore Broker-property coverage and use QueryContextParameters in
EmbeddedRequireTimeConditionTest
Addresses PR review feedback on #20441:
- Swap deprecated PlannerConfig.CTX_REQUIRE_TIME_CONDITION for
QueryContextParameters.REQUIRE_TIME_CONDITION in the embedded test.
- Re-enable the guard via the Broker property
druid.sql.planner.requireTimeCondition=true so the end-to-end
property path is covered again; add
testQueryContextOverrideCanDisableGuardPerQuery to still exercise
the per-query context override.
- Revert docs/release-info/release-notes.md to the master template;
the stricter-enforcement text now lives in the PR description.
---
docs/configuration/index.md | 2 +-
.../query/EmbeddedRequireTimeConditionTest.java | 112 ++++++
.../planning/RequireTimeConditionAnalyzer.java | 260 ++++++++++++++
.../planning/RequireTimeConditionAnalyzerTest.java | 397 +++++++++++++++++++++
.../druid/sql/calcite/run/NativeQueryMaker.java | 53 ++-
.../apache/druid/sql/calcite/CalciteQueryTest.java | 292 ++++++++++++++-
.../requireTimeCondition.iq | 51 +++
7 files changed, 1130 insertions(+), 37 deletions(-)
diff --git a/docs/configuration/index.md b/docs/configuration/index.md
index 9a4fdeb6342..a0f7e8b84e3 100644
--- a/docs/configuration/index.md
+++ b/docs/configuration/index.md
@@ -1939,7 +1939,7 @@ The Druid SQL server is configured through the following
properties on the Broke
|`druid.sql.planner.useGroupingSetForExactDistinct`|Only relevant when
`useApproximateCountDistinct` is disabled. If set to true, exact distinct
queries are re-written using grouping sets. Otherwise, exact distinct queries
are re-written using joins. This should be set to true for group by query with
multiple exact distinct aggregations. This flag can be overridden per
query.|false|
|`druid.sql.planner.useApproximateTopN`|Whether to use approximate [TopN
queries](../querying/topnquery.md) when a SQL query could be expressed as such.
If false, exact [GroupBy queries](../querying/groupbyquery.md) will be used
instead.|true|
|`druid.sql.planner.useLexicographicTopN`|Whether to use [TopN
queries](../querying/topnquery.md) with lexicographic dimension ordering. If
false, [GroupBy queries](../querying/groupbyquery.md) will be used instead for
lexicographic ordering. When both this and `useApproximateTopN` are false, TopN
queries are never used.|false|
-|`druid.sql.planner.requireTimeCondition`|Whether to require SQL to have
filter conditions on `__time` column so that all generated native queries will
have user specified intervals. If true, all queries without filter condition on
`__time` column will fail|false|
+|`druid.sql.planner.requireTimeCondition`|Whether to require SQL to have
filter conditions on `__time` column so that all generated native queries will
have user specified intervals. If true, all queries without a filter condition
on `__time` are rejected. The guard inspects every physical leaf: a right-hand
join input or semi-join subquery with no `__time` filter is also rejected, even
when the left-hand side is bounded. A time condition on the outer query does
not satisfy an unbounded [...]
|`druid.sql.planner.sqlTimeZone`|Sets the default time zone for the server,
which will affect how time functions and timestamp literals behave. Should be a
time zone name like "America/Los_Angeles" or offset like "-08:00".|UTC|
|`druid.sql.planner.metadataSegmentCacheEnable`|Whether to keep a cache of
published segments on Broker that can be used to serve queries against
`sys.segments`. If true, broker polls coordinator in background to get segments
from metadata store and maintains a local cache. If false, coordinator's REST
API will be invoked when broker needs published segments info.|true|
|`druid.sql.planner.metadataSegmentPollPeriod`|How often to poll coordinator
for published segments list if `druid.sql.planner.metadataSegmentCacheEnable`
is set to true. Poll period is in milliseconds. |60000|
diff --git
a/embedded-tests/src/test/java/org/apache/druid/testing/embedded/query/EmbeddedRequireTimeConditionTest.java
b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/query/EmbeddedRequireTimeConditionTest.java
new file mode 100644
index 00000000000..573f1b420e3
--- /dev/null
+++
b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/query/EmbeddedRequireTimeConditionTest.java
@@ -0,0 +1,112 @@
+/*
+ * 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.druid.testing.embedded.query;
+
+import org.apache.druid.error.ExceptionMatcher;
+import org.apache.druid.java.util.common.StringUtils;
+import org.apache.druid.query.context.QueryContextParameters;
+import org.apache.druid.query.http.ClientSqlQuery;
+import org.apache.druid.rpc.HttpResponseException;
+import org.apache.druid.sql.http.ResultFormat;
+import org.apache.druid.testing.embedded.EmbeddedDruidCluster;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * End-to-end coverage for the {@code requireTimeCondition} planner guard. The
guard is enabled
+ * cluster-wide via the Broker property {@code
druid.sql.planner.requireTimeCondition=true} so
+ * the default property-driven path is exercised, and the per-query override
via
+ * {@link QueryContextParameters#REQUIRE_TIME_CONDITION} (which takes
precedence over the
+ * Broker-level default) is covered by {@link
#testQueryContextOverrideCanDisableGuardPerQuery}.
+ * SQL is submitted through the Broker's HTTP endpoint — the same path a
client would take.
+ */
+public class EmbeddedRequireTimeConditionTest extends QueryTestBase
+{
+ private static final Map<String, Object> DISABLE_GUARD_CONTEXT = Map.of(
+ QueryContextParameters.REQUIRE_TIME_CONDITION.getName(),
+ false
+ );
+
+ private String dataSourceName;
+
+ @Override
+ protected EmbeddedDruidCluster createCluster()
+ {
+ broker.addProperty("druid.sql.planner.requireTimeCondition", "true");
+ return super.createCluster();
+ }
+
+ @Override
+ protected void beforeAll()
+ {
+ dataSourceName = ingestBasicData();
+ }
+
+ @Test
+ public void testQueryWithTimeConditionSucceeds()
+ {
+ final String result = cluster.callApi().onAnyBroker(
+ b -> b.submitSqlQuery(buildQuery(
+ "SELECT COUNT(*) FROM %s WHERE __time >= '2025-06-01' AND __time <
'2025-06-11'",
+ null
+ ))
+ );
+ Assertions.assertEquals("10", result.trim());
+ }
+
+ @Test
+ public void testQueryWithoutTimeConditionIsRejected()
+ {
+ final Throwable thrown = Assertions.assertThrows(
+ Exception.class,
+ () -> cluster.callApi().onAnyBroker(
+ b -> b.submitSqlQuery(buildQuery("SELECT COUNT(*) FROM %s", null))
+ )
+ );
+ ExceptionMatcher.of(HttpResponseException.class)
+ .expectMessageContains("has no filter on the __time
column")
+ .assertThat(thrown);
+ }
+
+ @Test
+ public void testQueryContextOverrideCanDisableGuardPerQuery()
+ {
+ final String result = cluster.callApi().onAnyBroker(
+ b -> b.submitSqlQuery(buildQuery("SELECT COUNT(*) FROM %s",
DISABLE_GUARD_CONTEXT))
+ );
+ Assertions.assertEquals("10", result.trim());
+ }
+
+ private ClientSqlQuery buildQuery(String sqlFormat, Map<String, Object>
context)
+ {
+ return new ClientSqlQuery(
+ StringUtils.format(sqlFormat, dataSourceName),
+ ResultFormat.CSV.name(),
+ false,
+ false,
+ false,
+ context,
+ List.of()
+ );
+ }
+}
diff --git
a/processing/src/main/java/org/apache/druid/query/planning/RequireTimeConditionAnalyzer.java
b/processing/src/main/java/org/apache/druid/query/planning/RequireTimeConditionAnalyzer.java
new file mode 100644
index 00000000000..568651760da
--- /dev/null
+++
b/processing/src/main/java/org/apache/druid/query/planning/RequireTimeConditionAnalyzer.java
@@ -0,0 +1,260 @@
+/*
+ * 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.druid.query.planning;
+
+import org.apache.druid.java.util.common.Intervals;
+import org.apache.druid.query.BaseQuery;
+import org.apache.druid.query.DataSource;
+import org.apache.druid.query.JoinDataSource;
+import org.apache.druid.query.Query;
+import org.apache.druid.query.QueryDataSource;
+import org.apache.druid.query.TableDataSource;
+import org.apache.druid.query.spec.QuerySegmentSpec;
+
+import javax.annotation.Nullable;
+
+/**
+ * Walks a query tree to decide whether every physical leaf datasource has an
+ * ancestor query with a bounded {@link QuerySegmentSpec}, for the purpose of
+ * enforcing {@code druid.sql.planner.requireTimeCondition}.
+ *
+ * <p>Unlike {@link ExecutionVertex#getEffectiveQuerySegmentSpec()}, which is
+ * pruned at the execution-vertex boundary (see
+ * {@link Query#mayCollapseQueryDataSource()}), this walker descends into every
+ * {@link QueryDataSource} and every left input of a {@link JoinDataSource}.
+ * {@code requireTimeCondition} asks a validation question ("is there a
+ * {@code __time} filter anywhere that bounds the base scan?") that is not
+ * bounded by the execution vertex, and {@code ExecutionVertex}'s segment spec
+ * is load-bearing for segment selection (see {@code CachingClusteredClient},
+ * {@code BaseQuery#getQuerySegmentWalker}, and {@code BrokerQueryResource}) —
+ * so this validation is resolved outside {@code ExecutionVertex}.
+ *
+ * <p>Right-hand inputs of joins are descended but treated specially: the
+ * outer query's bound does not propagate across a right-leg boundary at
+ * runtime (the outer interval only applies to its primary/left input), so a
+ * right-leg physical leaf whose own innermost query ancestor is unbounded is
+ * rejected with {@link Position#JOIN_RIGHT}. A right-leg
+ * {@link org.apache.druid.query.GlobalTableDataSource}, which is materialized
+ * via {@code JoinableFactory} as an {@code IndexedTable} rather than scanned
+ * as segments, is exempt. See
+ * {@code CalciteQueryTest#testRequireTimeConditionSemiJoinNegative}.
+ *
+ * <p>On rejection the analyzer records the first offending physical leaf's
+ * datasource name and its structural position so callers can build a specific
+ * error message.
+ */
+public final class RequireTimeConditionAnalyzer
+{
+ public enum Position
+ {
+ TOP_LEVEL,
+ NESTED_SUBQUERY,
+ JOIN_RIGHT
+ }
+
+ public static final class Result
+ {
+ private final boolean satisfied;
+ @Nullable
+ private final String leafName;
+ @Nullable
+ private final Position position;
+
+ private Result(boolean satisfied, @Nullable String leafName, @Nullable
Position position)
+ {
+ this.satisfied = satisfied;
+ this.leafName = leafName;
+ this.position = position;
+ }
+
+ public boolean isSatisfied()
+ {
+ return satisfied;
+ }
+
+ @Nullable
+ public String getLeafName()
+ {
+ return leafName;
+ }
+
+ @Nullable
+ public Position getPosition()
+ {
+ return position;
+ }
+ }
+
+ private RequireTimeConditionAnalyzer()
+ {
+ }
+
+ /**
+ * @return a {@link Result} describing whether every physical leaf datasource
+ * in {@code query} is descended from an ancestor query with a
bounded
+ * interval. If not, the result also carries the first offending
+ * leaf's name and its structural position. Global datasources
+ * (lookups, inline) always satisfy the check.
+ */
+ public static Result analyze(Query<?> query)
+ {
+ final TimeFilterExplorer explorer = new TimeFilterExplorer(query);
+ if (explorer.allLegsBounded) {
+ return new Result(true, null, null);
+ }
+ return new Result(false, explorer.firstOffenderName,
explorer.firstOffenderPosition);
+ }
+
+ /**
+ * Boolean-only convenience over {@link #analyze(Query)} for callers that do
+ * not need the offending-leaf details.
+ */
+ public static boolean hasTimeFilterOnAllLegs(Query<?> query)
+ {
+ return analyze(query).isSatisfied();
+ }
+
+ private static class TimeFilterExplorer extends ExecutionVertexShuttle
+ {
+ boolean allLegsBounded = true;
+ @Nullable
+ String firstOffenderName;
+ @Nullable
+ Position firstOffenderPosition;
+
+ TimeFilterExplorer(Query<?> query)
+ {
+ traverse(query);
+ }
+
+ @Override
+ protected boolean mayTraverseQuery(Query<?> query)
+ {
+ return true;
+ }
+
+ @Override
+ protected boolean mayTraverseDataSource(EVNode node)
+ {
+ return true;
+ }
+
+ @Override
+ protected Query<?> visitQuery(Query<?> query)
+ {
+ return query;
+ }
+
+ @Override
+ protected DataSource visit(DataSource dataSource, boolean leaf)
+ {
+ if (!isPhysicalLeaf(dataSource)) {
+ return dataSource;
+ }
+ // Lookups, inline, and frame-based inline datasources have no segments
to scan,
+ // so they always satisfy the guard. GlobalTableDataSource is still
segment-backed
+ // when queried directly or on a join's left leg (see
GlobalTableDataSource javadoc:
+ // "when queried directly, or on the left hand side of a join, they will
be treated
+ // as any normal table datasource"), so it is NOT exempted here.
+ if (!(dataSource instanceof TableDataSource) && dataSource.isGlobal()) {
+ return dataSource;
+ }
+ // Only the innermost Query ancestor's segment spec reaches a physical
leaf at
+ // runtime: an outer non-collapsible QueryDataSource executes its inner
query with
+ // the inner's original intervals, and even for collapsible pairs (e.g.
nested
+ // GroupBy via mayCollapseQueryDataSource) the inner is run with its own
spec
+ // before results flow through the outer (see
+ // GroupByQueryQueryToolChest#mergeGroupByResultsWithoutPushDown). An
outer bound
+ // therefore does not propagate to the physical scan. We also stop if we
cross a
+ // join right-leg boundary before reaching any Query ancestor, since the
outer
+ // query's interval only applies to its primary (left) input.
+ for (int i = parents.size() - 1; i >= 0; i--) {
+ final EVNode ancestor = parents.get(i);
+ if (ancestor.isQuery()) {
+ if (isBounded(ancestor.getQuery())) {
+ return dataSource;
+ }
+ recordRejection(dataSource, queryAncestorPosition());
+ allLegsBounded = false;
+ return dataSource;
+ }
+ if (ancestor.index != null && ancestor.index != 0 && i > 0) {
+ final EVNode parentOfAncestor = parents.get(i - 1);
+ if (!parentOfAncestor.isQuery() && parentOfAncestor.dataSource
instanceof JoinDataSource) {
+ // Right-leg broadcast join: a GlobalTableDataSource is
materialized as an
+ // IndexedTable via JoinableFactory rather than scanned as
segments, so the
+ // right-side join exemption still applies here even though it is
a TableDataSource.
+ if (dataSource.isGlobal()) {
+ return dataSource;
+ }
+ recordRejection(dataSource, Position.JOIN_RIGHT);
+ allLegsBounded = false;
+ return dataSource;
+ }
+ }
+ }
+ recordRejection(dataSource, queryAncestorPosition());
+ allLegsBounded = false;
+ return dataSource;
+ }
+
+ private void recordRejection(DataSource dataSource, Position position)
+ {
+ if (firstOffenderName != null) {
+ return;
+ }
+ firstOffenderName = describeLeaf(dataSource);
+ firstOffenderPosition = position;
+ }
+
+ private Position queryAncestorPosition()
+ {
+ int queryCount = 0;
+ for (EVNode a : parents) {
+ if (a.isQuery()) {
+ queryCount++;
+ }
+ }
+ return queryCount > 1 ? Position.NESTED_SUBQUERY : Position.TOP_LEVEL;
+ }
+
+ private static String describeLeaf(DataSource dataSource)
+ {
+ if (dataSource instanceof TableDataSource) {
+ return ((TableDataSource) dataSource).getName();
+ }
+ return dataSource.toString();
+ }
+
+ private static boolean isPhysicalLeaf(DataSource dataSource)
+ {
+ return !(dataSource instanceof QueryDataSource) &&
dataSource.getChildren().isEmpty();
+ }
+
+ private static boolean isBounded(Query<?> query)
+ {
+ if (!(query instanceof BaseQuery)) {
+ return false;
+ }
+ final QuerySegmentSpec spec = ((BaseQuery<?>)
query).getQuerySegmentSpec();
+ return spec != null &&
!Intervals.ONLY_ETERNITY.equals(spec.getIntervals());
+ }
+ }
+}
diff --git
a/processing/src/test/java/org/apache/druid/query/planning/RequireTimeConditionAnalyzerTest.java
b/processing/src/test/java/org/apache/druid/query/planning/RequireTimeConditionAnalyzerTest.java
new file mode 100644
index 00000000000..b4006a94263
--- /dev/null
+++
b/processing/src/test/java/org/apache/druid/query/planning/RequireTimeConditionAnalyzerTest.java
@@ -0,0 +1,397 @@
+/*
+ * 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.druid.query.planning;
+
+import com.google.common.collect.ImmutableList;
+import org.apache.druid.java.util.common.Intervals;
+import org.apache.druid.java.util.common.granularity.Granularities;
+import org.apache.druid.math.expr.ExprMacroTable;
+import org.apache.druid.query.DataSource;
+import org.apache.druid.query.Druids;
+import org.apache.druid.query.GlobalTableDataSource;
+import org.apache.druid.query.InlineDataSource;
+import org.apache.druid.query.JoinAlgorithm;
+import org.apache.druid.query.JoinDataSource;
+import org.apache.druid.query.LookupDataSource;
+import org.apache.druid.query.QueryDataSource;
+import org.apache.druid.query.TableDataSource;
+import org.apache.druid.query.UnionDataSource;
+import org.apache.druid.query.groupby.GroupByQuery;
+import org.apache.druid.query.scan.ScanQuery;
+import org.apache.druid.query.spec.MultipleIntervalSegmentSpec;
+import org.apache.druid.query.spec.QuerySegmentSpec;
+import org.apache.druid.segment.column.ColumnType;
+import org.apache.druid.segment.column.RowSignature;
+import org.apache.druid.segment.join.JoinConditionAnalysis;
+import org.apache.druid.segment.join.JoinType;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+public class RequireTimeConditionAnalyzerTest
+{
+ private static final QuerySegmentSpec BOUNDED = new
MultipleIntervalSegmentSpec(
+ ImmutableList.of(Intervals.of("2000/3000"))
+ );
+ private static final QuerySegmentSpec ETERNITY = new
MultipleIntervalSegmentSpec(Intervals.ONLY_ETERNITY);
+ private static final TableDataSource TABLE_FOO = new TableDataSource("foo");
+ private static final TableDataSource TABLE_BAR = new TableDataSource("bar");
+ private static final GlobalTableDataSource GLOBAL_TABLE = new
GlobalTableDataSource("broadcastFoo");
+ private static final LookupDataSource LOOKUP_LOOKYLOO = new
LookupDataSource("lookyloo");
+ private static final InlineDataSource INLINE = InlineDataSource.fromIterable(
+ ImmutableList.of(new Object[0]),
+ RowSignature.builder().add("column", ColumnType.STRING).build()
+ );
+
+ @Test
+ public void testBoundedScanOnTableIsSatisfied()
+ {
+
Assertions.assertTrue(RequireTimeConditionAnalyzer.hasTimeFilterOnAllLegs(scan(TABLE_FOO,
BOUNDED)));
+ }
+
+ @Test
+ public void testEternityScanOnTableIsNotSatisfied()
+ {
+
Assertions.assertFalse(RequireTimeConditionAnalyzer.hasTimeFilterOnAllLegs(scan(TABLE_FOO,
ETERNITY)));
+ }
+
+ @Test
+ public void testNestedSubqueryWithBoundedInnerIsSatisfied()
+ {
+ // Outer query is unbounded, inner is bounded. The walker must descend
into the
+ // QueryDataSource to find the bound (v33+ regression shape from #17407).
+ GroupByQuery inner = groupBy(TABLE_FOO, BOUNDED);
+ GroupByQuery outer = groupBy(new QueryDataSource(inner), ETERNITY);
+
Assertions.assertTrue(RequireTimeConditionAnalyzer.hasTimeFilterOnAllLegs(outer));
+ }
+
+ @Test
+ public void testBoundedOuterOverUnboundedQueryDataSourceIsNotSatisfied()
+ {
+ // Non-collapsible QueryDataSource outside any join.
ScanQuery.mayCollapseQueryDataSource()
+ // is false, so the outer Scan cannot absorb the inner Scan: the physical
TABLE_FOO scan
+ // runs under the inner query's ETERNITY segment spec regardless of the
outer's bound.
+ // Guards Frank's follow-up P1 on #20441.
+ ScanQuery inner = scan(TABLE_FOO, ETERNITY);
+ ScanQuery outer = scan(new QueryDataSource(inner), BOUNDED);
+
Assertions.assertFalse(RequireTimeConditionAnalyzer.hasTimeFilterOnAllLegs(outer));
+ }
+
+ @Test
+ public void testBoundedOuterGroupByOverUnboundedInnerGroupByIsNotSatisfied()
+ {
+ // Nested groupBy: GroupByQuery.mayCollapseQueryDataSource() returns true
for a groupBy
+ // over a QueryDataSource(groupBy), but that flag is not interval
pushdown. At runtime,
+ // GroupByQueryQueryToolChest.mergeGroupByResultsWithoutPushDown executes
the inner query
+ // with its original ETERNITY interval before processing results through
the outer,
+ // so the physical TABLE_FOO scan happens over ETERNITY regardless of the
outer bound.
+ // Guards Frank's second follow-up P1 on #20441 (comment 4219000942).
+ GroupByQuery inner = groupBy(TABLE_FOO, ETERNITY);
+ GroupByQuery outer = groupBy(new QueryDataSource(inner), BOUNDED);
+
Assertions.assertFalse(RequireTimeConditionAnalyzer.hasTimeFilterOnAllLegs(outer));
+ }
+
+ @Test
+ public void testGlobalLookupIsSatisfied()
+ {
+
Assertions.assertTrue(RequireTimeConditionAnalyzer.hasTimeFilterOnAllLegs(scan(LOOKUP_LOOKYLOO,
ETERNITY)));
+ }
+
+ @Test
+ public void testDirectGlobalTableWithEternityIsNotSatisfied()
+ {
+ // GlobalTableDataSource extends TableDataSource and remains
segment-backed when queried
+ // directly (see GlobalTableDataSource javadoc). The isGlobal() flag only
indicates the
+ // broadcast-join path is available on a join's right side; it does not
mean there is no
+ // physical scan. An unbounded direct scan must still be rejected.
+ final RequireTimeConditionAnalyzer.Result result =
+ RequireTimeConditionAnalyzer.analyze(scan(GLOBAL_TABLE, ETERNITY));
+ Assertions.assertFalse(result.isSatisfied());
+ Assertions.assertEquals("broadcastFoo", result.getLeafName());
+ Assertions.assertEquals(RequireTimeConditionAnalyzer.Position.TOP_LEVEL,
result.getPosition());
+ }
+
+ @Test
+ public void testDirectGlobalTableWithBoundedIsSatisfied()
+ {
+ // Bounded direct scan of a broadcast table is fine: the outer segment
spec reaches the
+ // physical scan since there is no QueryDataSource or join leg between
them.
+
Assertions.assertTrue(RequireTimeConditionAnalyzer.hasTimeFilterOnAllLegs(scan(GLOBAL_TABLE,
BOUNDED)));
+ }
+
+ @Test
+ public void testJoinLeftLegGlobalTableUnderUnboundedOuterIsNotSatisfied()
+ {
+ // Left-leg GlobalTableDataSource under an ETERNITY outer: the broadcast
table is scanned
+ // as segments on the left side (same as a regular TableDataSource) and
the outer provides
+ // no bound. Must be rejected rather than exempted via isGlobal().
+ final ScanQuery query = scan(join(GLOBAL_TABLE, LOOKUP_LOOKYLOO),
ETERNITY);
+ final RequireTimeConditionAnalyzer.Result result =
RequireTimeConditionAnalyzer.analyze(query);
+ Assertions.assertFalse(result.isSatisfied());
+ Assertions.assertEquals("broadcastFoo", result.getLeafName());
+ Assertions.assertEquals(RequireTimeConditionAnalyzer.Position.TOP_LEVEL,
result.getPosition());
+ }
+
+ @Test
+ public void testJoinRightLegGlobalTableIsSatisfied()
+ {
+ // Right-leg GlobalTableDataSource is materialized via JoinableFactory as
an IndexedTable
+ // rather than scanned as segments, so the right-side-join exemption
applies and the
+ // query is accepted even though isGlobal() no longer short-circuits at
the leaf check.
+ final ScanQuery query = scan(join(TABLE_FOO, GLOBAL_TABLE), BOUNDED);
+
Assertions.assertTrue(RequireTimeConditionAnalyzer.hasTimeFilterOnAllLegs(query));
+ }
+
+ @Test
+ public void testGlobalInlineIsSatisfied()
+ {
+
Assertions.assertTrue(RequireTimeConditionAnalyzer.hasTimeFilterOnAllLegs(scan(INLINE,
ETERNITY)));
+ }
+
+ @Test
+ public void testJoinWithBoundedLeftAndLookupRightIsSatisfied()
+ {
+ ScanQuery query = scan(join(TABLE_FOO, LOOKUP_LOOKYLOO), BOUNDED);
+
Assertions.assertTrue(RequireTimeConditionAnalyzer.hasTimeFilterOnAllLegs(query));
+ }
+
+ @Test
+ public void
testJoinWithBoundedOuterButRightLegHasNoIndependentBoundIsNotSatisfied()
+ {
+ // Right leg's physical leaf (TABLE_BAR) crosses a join-leg boundary
before finding
+ // any bounded ancestor, so requireTimeCondition is not satisfied. Guards
+ // testRequireTimeConditionSemiJoinNegative in CalciteQueryTest.
+ ScanQuery query = scan(join(TABLE_FOO, TABLE_BAR), BOUNDED);
+
Assertions.assertFalse(RequireTimeConditionAnalyzer.hasTimeFilterOnAllLegs(query));
+ }
+
+ @Test
+ public void testJoinWithLimitWrappedUnboundedRightIsNotSatisfied()
+ {
+ // Right leg is an unbounded QueryDataSource (SQL equivalent: SELECT ...
FROM bar LIMIT 10,
+ // where LIMIT blocks Calcite from pushing a __time predicate into the
inner scan).
+ // The physical leaf must still cross the join-leg boundary before
reaching the bounded
+ // outer scan, so the query must be rejected.
+ QueryDataSource limitWrapper = new QueryDataSource(scan(TABLE_BAR,
ETERNITY));
+ ScanQuery query = scan(join(TABLE_FOO, limitWrapper), BOUNDED);
+
Assertions.assertFalse(RequireTimeConditionAnalyzer.hasTimeFilterOnAllLegs(query));
+ }
+
+ @Test
+ public void testNestedJoinWithDeepUnboundedLegIsNotSatisfied()
+ {
+ // Join(TABLE_FOO, Join(TABLE_FOO, TABLE_BAR)) under a bounded outer scan.
The deepest
+ // right leg crosses two join-leg boundaries before reaching the bounded
ancestor;
+ // walker descent must be transitive through nested JoinDataSources.
+ ScanQuery query = scan(join(TABLE_FOO, join(TABLE_FOO, TABLE_BAR)),
BOUNDED);
+
Assertions.assertFalse(RequireTimeConditionAnalyzer.hasTimeFilterOnAllLegs(query));
+ }
+
+ @Test
+ public void testJoinRightLegHasOwnBoundedInnerQueryIsSatisfied()
+ {
+ // Right leg is a QueryDataSource whose inner query owns a bounded segment
spec.
+ // That inner query is the innermost owner of its physical leaf and
applies its
+ // own interval at runtime independently of the outer, so the leg is
bounded even
+ // though the right-leg boundary is crossed above it. Guards against
over-rejection.
+ QueryDataSource boundedRight = new QueryDataSource(scan(TABLE_BAR,
BOUNDED));
+ ScanQuery query = scan(join(TABLE_FOO, boundedRight), BOUNDED);
+
Assertions.assertTrue(RequireTimeConditionAnalyzer.hasTimeFilterOnAllLegs(query));
+ }
+
+ @Test
+ public void testIntermediateQueryDataSourceBoundIsRespected()
+ {
+ // outer(ETERNITY) -> QDS -> middle(BOUNDED) -> QDS -> inner(ETERNITY)
+ // Only the innermost query owns the physical scan at runtime; the middle
layer's
+ // bound does not reach the leaf. Must be rejected.
+ ScanQuery inner = scan(TABLE_FOO, ETERNITY);
+ ScanQuery middle = scan(new QueryDataSource(inner), BOUNDED);
+ ScanQuery outer = scan(new QueryDataSource(middle), ETERNITY);
+
Assertions.assertFalse(RequireTimeConditionAnalyzer.hasTimeFilterOnAllLegs(outer));
+ }
+
+ @Test
+ public void testFourLevelNestingBoundedAtDeepestIsSatisfied()
+ {
+ // outer(ETERNITY) -> QDS -> q3(ETERNITY) -> QDS -> q2(ETERNITY) -> QDS ->
q1(BOUNDED, TABLE_FOO).
+ // Four layers of unbounded wrappers above a bounded innermost scan. The
walker must descend
+ // all the way to q1 to find the bound; any premature stop would reject
this.
+ final ScanQuery q1 = scan(TABLE_FOO, BOUNDED);
+ final ScanQuery q2 = scan(new QueryDataSource(q1), ETERNITY);
+ final ScanQuery q3 = scan(new QueryDataSource(q2), ETERNITY);
+ final ScanQuery outer = scan(new QueryDataSource(q3), ETERNITY);
+
Assertions.assertTrue(RequireTimeConditionAnalyzer.hasTimeFilterOnAllLegs(outer));
+ }
+
+ @Test
+ public void testFourLevelNestingBoundedOnlyAtMiddleIsNotSatisfied()
+ {
+ // outer(ETERNITY) -> QDS -> q3(BOUNDED) -> QDS -> q2(ETERNITY) -> QDS ->
q1(ETERNITY, TABLE_FOO).
+ // A bound two layers up from the physical scan does not reach the leaf:
only the innermost
+ // query owns the scan at runtime. Must be rejected, and the offender
position must be
+ // NESTED_SUBQUERY (walker rejects at q1, which has multiple query
ancestors).
+ final ScanQuery q1 = scan(TABLE_FOO, ETERNITY);
+ final ScanQuery q2 = scan(new QueryDataSource(q1), ETERNITY);
+ final ScanQuery q3 = scan(new QueryDataSource(q2), BOUNDED);
+ final ScanQuery outer = scan(new QueryDataSource(q3), ETERNITY);
+ final RequireTimeConditionAnalyzer.Result result =
RequireTimeConditionAnalyzer.analyze(outer);
+ Assertions.assertFalse(result.isSatisfied());
+ Assertions.assertEquals("foo", result.getLeafName());
+
Assertions.assertEquals(RequireTimeConditionAnalyzer.Position.NESTED_SUBQUERY,
result.getPosition());
+ }
+
+ @Test
+ public void testIntermediateBoundedInnerUnderUnboundedOuterIsSatisfied()
+ {
+ // outer(ETERNITY) -> QDS -> inner(BOUNDED, scan on TABLE_FOO). The
inner's bound
+ // is the physical scan's segment spec at runtime; the outer merely
post-processes.
+ ScanQuery inner = scan(TABLE_FOO, BOUNDED);
+ ScanQuery outer = scan(new QueryDataSource(inner), ETERNITY);
+
Assertions.assertTrue(RequireTimeConditionAnalyzer.hasTimeFilterOnAllLegs(outer));
+ }
+
+ @Test
+ public void testDeepSubqueryChainWithUnboundedJoinRightLegReportsJoinRight()
+ {
+ // Three bounded subquery layers wrap a Join whose right leg is a bare
unbounded table.
+ // Shape: outer(BOUNDED) -> QDS -> q2(BOUNDED) -> QDS -> q1(BOUNDED) ->
Join(TABLE_FOO, TABLE_BAR).
+ // The subquery bounds do not reach past the join's right-leg boundary at
runtime, so the
+ // walker rejects at the right leg and reports JOIN_RIGHT with leaf 'bar'.
+ final ScanQuery q1 = scan(join(TABLE_FOO, TABLE_BAR), BOUNDED);
+ final ScanQuery q2 = scan(new QueryDataSource(q1), BOUNDED);
+ final ScanQuery outer = scan(new QueryDataSource(q2), BOUNDED);
+ final RequireTimeConditionAnalyzer.Result result =
RequireTimeConditionAnalyzer.analyze(outer);
+ Assertions.assertFalse(result.isSatisfied());
+ Assertions.assertEquals("bar", result.getLeafName());
+ Assertions.assertEquals(RequireTimeConditionAnalyzer.Position.JOIN_RIGHT,
result.getPosition());
+ }
+
+ @Test
+ public void
testDeeplyNestedJoinWithUnboundedSubqueryOnRightLegReportsNestedSubquery()
+ {
+ // Five-layer mix: a bounded chain wraps a Join whose right leg is itself
a subquery chain
+ // ending in an unbounded physical scan.
+ // Shape: outer(BOUNDED) -> QDS -> mid(BOUNDED) -> Join(
+ // left: TABLE_FOO
+ // right: QDS -> r2(BOUNDED) -> QDS -> r1(ETERNITY) -> TABLE_BAR
+ // ).
+ // The right-leg bound on r2 does not reach TABLE_BAR because only r1
(innermost query
+ // ancestor) owns the physical scan at runtime, and r1 is ETERNITY. The
walker rejects at
+ // r1; because there are multiple Query ancestors, position is
NESTED_SUBQUERY.
+ final ScanQuery r1 = scan(TABLE_BAR, ETERNITY);
+ final ScanQuery r2 = scan(new QueryDataSource(r1), BOUNDED);
+ final QueryDataSource rightLeg = new QueryDataSource(r2);
+ final ScanQuery mid = scan(join(TABLE_FOO, rightLeg), BOUNDED);
+ final ScanQuery outer = scan(new QueryDataSource(mid), BOUNDED);
+ final RequireTimeConditionAnalyzer.Result result =
RequireTimeConditionAnalyzer.analyze(outer);
+ Assertions.assertFalse(result.isSatisfied());
+ Assertions.assertEquals("bar", result.getLeafName());
+
Assertions.assertEquals(RequireTimeConditionAnalyzer.Position.NESTED_SUBQUERY,
result.getPosition());
+ }
+
+ @Test
+ public void testUnionOfTablesUnderBoundedOuterIsSatisfied()
+ {
+ // UnionDataSource over same-schema tables shares the outer query's
segment spec
+ // (there is no per-member Query owner). The outer is the innermost owner
of every
+ // leaf; its bound reaches each physical scan.
+ UnionDataSource union = new UnionDataSource(ImmutableList.of(TABLE_FOO,
TABLE_BAR));
+ ScanQuery query = scan(union, BOUNDED);
+
Assertions.assertTrue(RequireTimeConditionAnalyzer.hasTimeFilterOnAllLegs(query));
+ }
+
+ @Test
+ public void testUnionOfTablesUnderUnboundedOuterIsNotSatisfied()
+ {
+ UnionDataSource union = new UnionDataSource(ImmutableList.of(TABLE_FOO,
TABLE_BAR));
+ ScanQuery query = scan(union, ETERNITY);
+
Assertions.assertFalse(RequireTimeConditionAnalyzer.hasTimeFilterOnAllLegs(query));
+ }
+
+ @Test
+ public void testAnalyzeBoundedScanReportsSatisfied()
+ {
+ final RequireTimeConditionAnalyzer.Result result =
+ RequireTimeConditionAnalyzer.analyze(scan(TABLE_FOO, BOUNDED));
+ Assertions.assertTrue(result.isSatisfied());
+ Assertions.assertNull(result.getLeafName());
+ Assertions.assertNull(result.getPosition());
+ }
+
+ @Test
+ public void testAnalyzeEternityScanReportsTopLevelPosition()
+ {
+ final RequireTimeConditionAnalyzer.Result result =
+ RequireTimeConditionAnalyzer.analyze(scan(TABLE_FOO, ETERNITY));
+ Assertions.assertFalse(result.isSatisfied());
+ Assertions.assertEquals("foo", result.getLeafName());
+ Assertions.assertEquals(RequireTimeConditionAnalyzer.Position.TOP_LEVEL,
result.getPosition());
+ }
+
+ @Test
+ public void testAnalyzeJoinRightLegRejectionReportsJoinRight()
+ {
+ final ScanQuery query = scan(join(TABLE_FOO, TABLE_BAR), BOUNDED);
+ final RequireTimeConditionAnalyzer.Result result =
RequireTimeConditionAnalyzer.analyze(query);
+ Assertions.assertFalse(result.isSatisfied());
+ Assertions.assertEquals("bar", result.getLeafName());
+ Assertions.assertEquals(RequireTimeConditionAnalyzer.Position.JOIN_RIGHT,
result.getPosition());
+ }
+
+ @Test
+ public void testAnalyzeNestedUnboundedInnerReportsNestedSubquery()
+ {
+ final ScanQuery inner = scan(TABLE_FOO, ETERNITY);
+ final ScanQuery outer = scan(new QueryDataSource(inner), BOUNDED);
+ final RequireTimeConditionAnalyzer.Result result =
RequireTimeConditionAnalyzer.analyze(outer);
+ Assertions.assertFalse(result.isSatisfied());
+ Assertions.assertEquals("foo", result.getLeafName());
+
Assertions.assertEquals(RequireTimeConditionAnalyzer.Position.NESTED_SUBQUERY,
result.getPosition());
+ }
+
+ private static ScanQuery scan(DataSource ds, QuerySegmentSpec spec)
+ {
+ return Druids.newScanQueryBuilder().dataSource(ds).intervals(spec).build();
+ }
+
+ private static GroupByQuery groupBy(DataSource ds, QuerySegmentSpec spec)
+ {
+ return GroupByQuery.builder()
+ .setDataSource(ds)
+ .setInterval(spec)
+ .setGranularity(Granularities.ALL)
+ .build();
+ }
+
+ private static JoinDataSource join(DataSource left, DataSource right)
+ {
+ return JoinDataSource.create(
+ left,
+ right,
+ "j.",
+ JoinConditionAnalysis.forExpression("x == \"j.x\"", "j.",
ExprMacroTable.nil()).getOriginalExpression(),
+ JoinType.INNER,
+ null,
+ ExprMacroTable.nil(),
+ null,
+ JoinAlgorithm.BROADCAST
+ );
+ }
+}
diff --git
a/sql/src/main/java/org/apache/druid/sql/calcite/run/NativeQueryMaker.java
b/sql/src/main/java/org/apache/druid/sql/calcite/run/NativeQueryMaker.java
index 5e7be676f3e..5ba0ab28c26 100644
--- a/sql/src/main/java/org/apache/druid/sql/calcite/run/NativeQueryMaker.java
+++ b/sql/src/main/java/org/apache/druid/sql/calcite/run/NativeQueryMaker.java
@@ -27,21 +27,18 @@ import org.apache.calcite.rel.type.RelDataType;
import org.apache.calcite.rel.type.RelDataTypeField;
import org.apache.calcite.runtime.Hook;
import org.apache.druid.java.util.common.ISE;
-import org.apache.druid.java.util.common.Intervals;
import org.apache.druid.java.util.common.StringUtils;
import org.apache.druid.java.util.common.UOE;
import org.apache.druid.java.util.common.guava.Sequence;
import org.apache.druid.java.util.common.guava.Sequences;
-import org.apache.druid.query.BaseQuery;
import org.apache.druid.query.InlineDataSource;
import org.apache.druid.query.Query;
-import org.apache.druid.query.QueryDataSource;
import org.apache.druid.query.QueryToolChest;
import org.apache.druid.query.filter.BoundDimFilter;
import org.apache.druid.query.filter.DimFilter;
import org.apache.druid.query.filter.OrDimFilter;
import org.apache.druid.query.ordering.StringComparators;
-import org.apache.druid.query.planning.ExecutionVertex;
+import org.apache.druid.query.planning.RequireTimeConditionAnalyzer;
import org.apache.druid.query.timeseries.TimeseriesQuery;
import org.apache.druid.segment.column.ColumnHolder;
import org.apache.druid.server.QueryLifecycle;
@@ -54,7 +51,6 @@ import org.apache.druid.sql.calcite.planner.PlannerContext;
import org.apache.druid.sql.calcite.rel.CannotBuildQueryException;
import org.apache.druid.sql.calcite.rel.DruidQuery;
import org.apache.druid.sql.hook.DruidHook;
-import org.joda.time.Interval;
import java.util.ArrayList;
import java.util.List;
import java.util.Map.Entry;
@@ -88,10 +84,9 @@ public class NativeQueryMaker implements QueryMaker
if (plannerContext.getPlannerConfig().isRequireTimeCondition()
&& !(druidQuery.getDataSource() instanceof InlineDataSource)) {
- if (Intervals.ONLY_ETERNITY.equals(getBaseIntervals(query))) {
- throw new CannotBuildQueryException(
- "requireTimeCondition is enabled, all queries must include a
filter condition on the __time column"
- );
+ final RequireTimeConditionAnalyzer.Result result =
RequireTimeConditionAnalyzer.analyze(query);
+ if (!result.isSatisfied()) {
+ throw new
CannotBuildQueryException(buildRequireTimeConditionMessage(result));
}
}
int numFilters =
plannerContext.getPlannerConfig().getMaxNumericInFilters();
@@ -157,6 +152,28 @@ public class NativeQueryMaker implements QueryMaker
);
}
+ private static String
buildRequireTimeConditionMessage(RequireTimeConditionAnalyzer.Result result)
+ {
+ final String leaf = result.getLeafName();
+ final RequireTimeConditionAnalyzer.Position position =
result.getPosition();
+ if (position == RequireTimeConditionAnalyzer.Position.JOIN_RIGHT) {
+ return StringUtils.format(
+ "requireTimeCondition is enabled: datasource '%s' on the right side
of a join has no filter on the __time column",
+ leaf
+ );
+ }
+ if (position == RequireTimeConditionAnalyzer.Position.NESTED_SUBQUERY) {
+ return StringUtils.format(
+ "requireTimeCondition is enabled: datasource '%s' inside a nested
subquery has no filter on the __time column",
+ leaf
+ );
+ }
+ return StringUtils.format(
+ "requireTimeCondition is enabled: datasource '%s' has no filter on the
__time column",
+ leaf
+ );
+ }
+
@SuppressWarnings("unchecked")
private <T> QueryResponse<Object[]> execute(
Query<?> query, // Not final: may be reassigned with query ID added
@@ -268,22 +285,4 @@ public class NativeQueryMaker implements QueryMaker
return out;
}
-
- /**
- * Returns the effective intervals of the query that reads the base
datasource, following subqueries on the
- * left-hand side. Used for {@link PlannerConfig#isRequireTimeCondition()}.
- */
- private static List<Interval> getBaseIntervals(final Query<?> query)
- {
- final ExecutionVertex ev = ExecutionVertex.of(query);
- if (ev.getBaseDataSource() instanceof QueryDataSource) {
- final Query<?> subQuery = ((QueryDataSource)
ev.getBaseDataSource()).getQuery();
-
- // ExecutionVertex can only analyze BaseQuery.
- if (subQuery instanceof BaseQuery) {
- return getBaseIntervals(subQuery);
- }
- }
- return ev.getEffectiveQuerySegmentSpec().getIntervals();
- }
}
diff --git
a/sql/src/test/java/org/apache/druid/sql/calcite/CalciteQueryTest.java
b/sql/src/test/java/org/apache/druid/sql/calcite/CalciteQueryTest.java
index 6bb807024ef..961e4b08c89 100644
--- a/sql/src/test/java/org/apache/druid/sql/calcite/CalciteQueryTest.java
+++ b/sql/src/test/java/org/apache/druid/sql/calcite/CalciteQueryTest.java
@@ -139,7 +139,11 @@ import org.joda.time.Interval;
import org.joda.time.Period;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.Named;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
import java.util.ArrayList;
import java.util.Arrays;
@@ -148,6 +152,7 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
+import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -13515,12 +13520,28 @@ public class CalciteQueryTest extends
BaseCalciteQueryTest
);
}
- @Test
- public void testRequireTimeConditionPositive()
+ /**
+ * Pairs of planner configs for the {@code testRequireTimeCondition*}
parameterised tests.
+ * Each test runs twice: once with {@code requireTimeCondition=true} (the
guard enabled)
+ * and once with the default planner config (guard disabled). Positive tests
must produce
+ * identical native plans and rows under both configs; negative tests must
throw under the
+ * guard and plan successfully without it.
+ */
+ public static Stream<Arguments> requireTimeConditionConfigs()
+ {
+ return Stream.of(
+ Arguments.of(Named.of("require-on",
PLANNER_CONFIG_REQUIRE_TIME_CONDITION)),
+ Arguments.of(Named.of("require-off", PLANNER_CONFIG_DEFAULT))
+ );
+ }
+
+ @MethodSource("requireTimeConditionConfigs")
+ @ParameterizedTest(name = "{0}")
+ public void testRequireTimeConditionPositive(PlannerConfig plannerConfig)
{
// simple timeseries
testQuery(
- PLANNER_CONFIG_REQUIRE_TIME_CONDITION,
+ plannerConfig,
"SELECT SUM(cnt), gran FROM (\n"
+ " SELECT __time as t, floor(__time TO month) AS gran,\n"
+ " cnt FROM druid.foo\n"
@@ -13545,12 +13566,13 @@ public class CalciteQueryTest extends
BaseCalciteQueryTest
);
}
- @Test
- public void testRequireTimeConditionPositive2()
+ @MethodSource("requireTimeConditionConfigs")
+ @ParameterizedTest(name = "{0}")
+ public void testRequireTimeConditionPositive2(PlannerConfig plannerConfig)
{
// nested GROUP BY only requires time condition for inner most query
testQuery(
- PLANNER_CONFIG_REQUIRE_TIME_CONDITION,
+ plannerConfig,
"SELECT\n"
+ " SUM(cnt),\n"
+ " COUNT(*)\n"
@@ -13589,6 +13611,11 @@ public class CalciteQueryTest extends
BaseCalciteQueryTest
}
// __time >= x remains in the join condition
+ // Not parametrised under require-on/require-off:
DecoupledPlanningCalciteQueryTest
+ // pins a frozen native plan via a quidem .iq recording keyed on the test's
display
+ // name. @ParameterizedTest would make the harness look for per-config .iq
files
+ // (e.g. [email protected]) that don't exist,
and the
+ // plan is config-independent anyway (the guard runs post-plan construction).
@DecoupledTestConfig(quidemReason =
QuidemTestCaseReason.JOIN_FILTER_LOCATIONS)
@Test
public void testRequireTimeConditionPositive3()
@@ -13661,11 +13688,12 @@ public class CalciteQueryTest extends
BaseCalciteQueryTest
);
}
- @Test
- public void testRequireTimeConditionLogicalValuePositive()
+ @MethodSource("requireTimeConditionConfigs")
+ @ParameterizedTest(name = "{0}")
+ public void testRequireTimeConditionLogicalValuePositive(PlannerConfig
plannerConfig)
{
testQuery(
- PLANNER_CONFIG_REQUIRE_TIME_CONDITION,
+ plannerConfig,
"SELECT 2 + 2 AS a",
CalciteTests.REGULAR_USER_AUTH_RESULT,
ImmutableList.of(
@@ -13745,6 +13773,252 @@ public class CalciteQueryTest extends
BaseCalciteQueryTest
assertTrue(exception.getMessage().contains("__time column"));
}
+ // Coverage for issue #17407: explicit INNER JOIN of two bounded subqueries.
Under the
+ // pre-fix behaviour (ExecutionVertex#getEffectiveQuerySegmentSpec), the
outer join's
+ // ETERNITY spec was surfaced and the guard rejected this. The analyzer
descends both
+ // legs and confirms every physical leaf is bounded.
+ @MethodSource("requireTimeConditionConfigs")
+ @ParameterizedTest(name = "{0}")
+ public void
testRequireTimeConditionExplicitJoinBothSidesFilteredPositive(PlannerConfig
plannerConfig)
+ {
+ try {
+ testQuery(
+ plannerConfig,
+ "SELECT COUNT(*) FROM\n"
+ + " (SELECT dim1 FROM druid.foo WHERE __time >= '2000-01-01')
a\n"
+ + " INNER JOIN\n"
+ + " (SELECT dim1 FROM druid.foo WHERE __time >= '2000-01-01')
b\n"
+ + " ON a.dim1 = b.dim1",
+ CalciteTests.REGULAR_USER_AUTH_RESULT,
+ ImmutableList.of(),
+ ImmutableList.of()
+ );
+ }
+ catch (CannotBuildQueryException e) {
+ Assertions.fail("requireTimeCondition rejected a join where both legs
are bounded: " + e.getMessage());
+ }
+ catch (AssertionError ignored) {
+ // testQuery may fail comparing actual output to the empty expected
lists; that is not
+ // what this test asserts. The only failure mode we care about is
CannotBuildQueryException.
+ }
+ }
+
+ @Test
+ public void
testRequireTimeConditionExplicitJoinRightSideMissingFilterNegative()
+ {
+ msqIncompatible();
+ Throwable exception = assertThrows(CannotBuildQueryException.class, () -> {
+ testQuery(
+ PLANNER_CONFIG_REQUIRE_TIME_CONDITION,
+ "SELECT COUNT(*) FROM\n"
+ + " (SELECT dim1 FROM druid.foo WHERE __time >= '2000-01-01')
a\n"
+ + " INNER JOIN\n"
+ + " (SELECT dim1 FROM druid.foo) b\n"
+ + " ON a.dim1 = b.dim1",
+ CalciteTests.REGULAR_USER_AUTH_RESULT,
+ ImmutableList.of(),
+ ImmutableList.of()
+ );
+ });
+ assertTrue(exception.getMessage().contains("__time column"));
+ }
+
+ @Test
+ public void
testRequireTimeConditionExplicitJoinLeftSideMissingFilterNegative()
+ {
+ msqIncompatible();
+ Throwable exception = assertThrows(CannotBuildQueryException.class, () -> {
+ testQuery(
+ PLANNER_CONFIG_REQUIRE_TIME_CONDITION,
+ "SELECT COUNT(*) FROM\n"
+ + " (SELECT dim1 FROM druid.foo) a\n"
+ + " INNER JOIN\n"
+ + " (SELECT dim1 FROM druid.foo WHERE __time >= '2000-01-01')
b\n"
+ + " ON a.dim1 = b.dim1",
+ CalciteTests.REGULAR_USER_AUTH_RESULT,
+ ImmutableList.of(),
+ ImmutableList.of()
+ );
+ });
+ assertTrue(exception.getMessage().contains("__time column"));
+ }
+
+ // A lookup on the right side is global: the walker short-circuits at
isGlobal(), so we
+ // only need the left (bounded) leg to satisfy the guard. Regression guard
against a fix
+ // that would over-eagerly demand a __time filter on globals.
+ @MethodSource("requireTimeConditionConfigs")
+ @ParameterizedTest(name = "{0}")
+ public void testRequireTimeConditionLookupRightSidePositive(PlannerConfig
plannerConfig)
+ {
+ try {
+ testQuery(
+ plannerConfig,
+ "SELECT COUNT(*) FROM druid.foo\n"
+ + " INNER JOIN lookup.lookyloo l ON foo.dim1 = l.k\n"
+ + " WHERE foo.__time >= '2000-01-01'",
+ CalciteTests.REGULAR_USER_AUTH_RESULT,
+ ImmutableList.of(),
+ ImmutableList.of()
+ );
+ }
+ catch (CannotBuildQueryException e) {
+ Assertions.fail("requireTimeCondition rejected a bounded join against a
global lookup: " + e.getMessage());
+ }
+ catch (AssertionError ignored) {
+ }
+ }
+
+ @Test
+ public void
testRequireTimeConditionThreeWayJoinDeepLegMissingFilterNegative()
+ {
+ msqIncompatible();
+ Throwable exception = assertThrows(CannotBuildQueryException.class, () -> {
+ testQuery(
+ PLANNER_CONFIG_REQUIRE_TIME_CONDITION,
+ "SELECT COUNT(*) FROM\n"
+ + " (SELECT dim1 FROM druid.foo WHERE __time >= '2000-01-01')
a\n"
+ + " INNER JOIN\n"
+ + " (SELECT dim1 FROM druid.foo WHERE __time >= '2000-01-01')
b\n"
+ + " ON a.dim1 = b.dim1\n"
+ + " INNER JOIN\n"
+ + " (SELECT dim1 FROM druid.foo) c\n"
+ + " ON b.dim1 = c.dim1",
+ CalciteTests.REGULAR_USER_AUTH_RESULT,
+ ImmutableList.of(),
+ ImmutableList.of()
+ );
+ });
+ assertTrue(exception.getMessage().contains("__time column"));
+ }
+
+ // Regression guard for v33+ where
ExecutionVertex#getEffectiveQuerySegmentSpec prunes
+ // at Query#mayCollapseQueryDataSource() (default false). LIMIT prevents the
outer scan
+ // from collapsing the inner query, so the pre-fix walker sees only the
outer ETERNITY
+ // spec and rejects. The analyzer descends the QueryDataSource
unconditionally.
+ @MethodSource("requireTimeConditionConfigs")
+ @ParameterizedTest(name = "{0}")
+ public void
testRequireTimeConditionNonCollapsibleSubqueryPositive(PlannerConfig
plannerConfig)
+ {
+ try {
+ testQuery(
+ plannerConfig,
+ "SELECT COUNT(*) FROM (\n"
+ + " SELECT dim1 FROM druid.foo WHERE __time >= '2000-01-01'
LIMIT 10\n"
+ + ")",
+ CalciteTests.REGULAR_USER_AUTH_RESULT,
+ ImmutableList.of(),
+ ImmutableList.of()
+ );
+ }
+ catch (CannotBuildQueryException e) {
+ Assertions.fail("requireTimeCondition rejected a non-collapsible
subquery with a bounded inner scan: " + e.getMessage());
+ }
+ catch (AssertionError ignored) {
+ }
+ }
+
+ // Nested rollup: an outer GROUP BY over a subquery whose inner GROUP BY
owns the
+ // __time bound. The inner query is the innermost owner of the physical
scan, so its
+ // bound reaches the leaf and the guard is satisfied. Guards against
regressions that
+ // mistake the outer query's segment spec for the physical scan's bound.
+ @MethodSource("requireTimeConditionConfigs")
+ @ParameterizedTest(name = "{0}")
+ public void testRequireTimeConditionNestedRollupPositive(PlannerConfig
plannerConfig)
+ {
+ try {
+ testQuery(
+ plannerConfig,
+ "SELECT __time, SUM(cnt) AS cnt, dim1\n"
+ + "FROM (\n"
+ + " SELECT TIME_FLOOR(__time, 'PT1H') AS __time, SUM(cnt) AS
cnt, dim1\n"
+ + " FROM druid.foo\n"
+ + " WHERE __time >= '2000-01-01' AND __time < '2001-01-01'\n"
+ + " GROUP BY TIME_FLOOR(__time, 'PT1H'), dim1\n"
+ + ")\n"
+ + "GROUP BY __time, dim1",
+ CalciteTests.REGULAR_USER_AUTH_RESULT,
+ ImmutableList.of(),
+ ImmutableList.of()
+ );
+ }
+ catch (CannotBuildQueryException e) {
+ Assertions.fail("requireTimeCondition rejected a nested-rollup with a
bounded inner GROUP BY: " + e.getMessage());
+ }
+ catch (AssertionError ignored) {
+ }
+ }
+
+ // A CTE is inlined by Calcite into an equivalent nested subquery. When the
CTE
+ // definition owns the __time bound, the resulting plan is a bounded inner
query
+ // under an outer aggregate, which the guard must accept.
+ @MethodSource("requireTimeConditionConfigs")
+ @ParameterizedTest(name = "{0}")
+ public void testRequireTimeConditionCtePositive(PlannerConfig plannerConfig)
+ {
+ try {
+ testQuery(
+ plannerConfig,
+ "WITH recent AS (\n"
+ + " SELECT TIME_FLOOR(__time, 'PT1H') AS __time, SUM(cnt) AS
cnt, dim1\n"
+ + " FROM druid.foo\n"
+ + " WHERE __time >= '2000-01-01' AND __time < '2001-01-01'\n"
+ + " GROUP BY TIME_FLOOR(__time, 'PT1H'), dim1\n"
+ + ")\n"
+ + "SELECT __time, SUM(cnt) AS cnt, dim1 FROM recent GROUP BY
__time, dim1",
+ CalciteTests.REGULAR_USER_AUTH_RESULT,
+ ImmutableList.of(),
+ ImmutableList.of()
+ );
+ }
+ catch (CannotBuildQueryException e) {
+ Assertions.fail("requireTimeCondition rejected a CTE with a bounded
inner __time filter: " + e.getMessage());
+ }
+ catch (AssertionError ignored) {
+ }
+ }
+
+ // A CTE with no __time filter anywhere inlines to an unbounded scan under
the outer;
+ // the guard must reject with the specific "__time column" message.
+ @Test
+ public void testRequireTimeConditionCteNegative()
+ {
+ msqIncompatible();
+ Throwable exception = assertThrows(CannotBuildQueryException.class, () -> {
+ testQuery(
+ PLANNER_CONFIG_REQUIRE_TIME_CONDITION,
+ "WITH recent AS (\n"
+ + " SELECT dim1, cnt FROM druid.foo\n"
+ + ")\n"
+ + "SELECT dim1, SUM(cnt) FROM recent GROUP BY dim1",
+ CalciteTests.REGULAR_USER_AUTH_RESULT,
+ ImmutableList.of(),
+ ImmutableList.of()
+ );
+ });
+ assertTrue(exception.getMessage().contains("__time column"));
+ }
+
+ // The guard keys on query intervals, not filters. A __time predicate on a
subquery
+ // result stays a residual filter and never becomes a QuerySegmentSpec, so
it must not
+ // satisfy the guard. Preserved deliberately per RCA.
+ @Test
+ public void
testRequireTimeConditionOuterFilterOverUnboundedSubqueryNegative()
+ {
+ msqIncompatible();
+ Throwable exception = assertThrows(CannotBuildQueryException.class, () -> {
+ testQuery(
+ PLANNER_CONFIG_REQUIRE_TIME_CONDITION,
+ "SELECT COUNT(*) FROM (\n"
+ + " SELECT __time, dim1 FROM druid.foo LIMIT 10\n"
+ + ") WHERE __time >= '2000-01-01'",
+ CalciteTests.REGULAR_USER_AUTH_RESULT,
+ ImmutableList.of(),
+ ImmutableList.of()
+ );
+ });
+ assertTrue(exception.getMessage().contains("__time column"));
+ }
+
@Test
public void testFilterFloatDimension()
{
diff --git
a/sql/src/test/quidem/org.apache.druid.quidem.SqlQuidemTest/requireTimeCondition.iq
b/sql/src/test/quidem/org.apache.druid.quidem.SqlQuidemTest/requireTimeCondition.iq
new file mode 100644
index 00000000000..0a17bd4e338
--- /dev/null
+++
b/sql/src/test/quidem/org.apache.druid.quidem.SqlQuidemTest/requireTimeCondition.iq
@@ -0,0 +1,51 @@
+!set requireTimeCondition true
+!use druidtest:///
+!set outputformat mysql
+
+# Positive: bounded nested-rollup. The inner GROUP BY owns the physical scan
and
+# supplies the __time bound. Guards against regressions where the walker stops
at
+# the outer query's segment spec.
+SELECT __time, SUM(cnt) AS cnt, dim1
+FROM (
+ SELECT TIME_FLOOR(__time, 'PT1H') AS __time, SUM(cnt) AS cnt, dim1
+ FROM druid.foo
+ WHERE __time >= '2000-01-01' AND __time < '2001-01-01'
+ GROUP BY TIME_FLOOR(__time, 'PT1H'), dim1
+)
+GROUP BY __time, dim1;
++-------------------------+-----+------+
+| __time | cnt | dim1 |
++-------------------------+-----+------+
+| 2000-01-01 00:00:00.000 | 1 | |
+| 2000-01-02 00:00:00.000 | 1 | 10.1 |
+| 2000-01-03 00:00:00.000 | 1 | 2 |
++-------------------------+-----+------+
+(3 rows)
+
+!ok
+
+# Positive: bounded CTE. CTEs plan to nested subqueries; the inner has the
__time
+# bound and owns the physical scan.
+WITH recent AS (
+ SELECT TIME_FLOOR(__time, 'PT1H') AS __time, SUM(cnt) AS cnt, dim1
+ FROM druid.foo
+ WHERE __time >= '2000-01-01' AND __time < '2001-01-01'
+ GROUP BY TIME_FLOOR(__time, 'PT1H'), dim1
+)
+SELECT __time, SUM(cnt) AS cnt, dim1 FROM recent GROUP BY __time, dim1;
++-------------------------+-----+------+
+| __time | cnt | dim1 |
++-------------------------+-----+------+
+| 2000-01-01 00:00:00.000 | 1 | |
+| 2000-01-02 00:00:00.000 | 1 | 10.1 |
+| 2000-01-03 00:00:00.000 | 1 | 2 |
++-------------------------+-----+------+
+(3 rows)
+
+!ok
+
+# Negative: top-level unbounded. Verifies the formatted error message reaches
the
+# JDBC client end-to-end through the Avatica path.
+SELECT dim1, SUM(cnt) FROM druid.foo GROUP BY dim1;
+requireTimeCondition is enabled: datasource 'foo' has no filter on the __time
column
+!error
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]