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 585459a6239 feat!: add typed query context parameters (#20198)
585459a6239 is described below
commit 585459a62394a53da4b6ecfbafdbc59ba6a720f6
Author: Frank Chen <[email protected]>
AuthorDate: Sun Oct 11 14:56:52 2026 +0800
feat!: add typed query context parameters (#20198)
* feat!: add typed query context parameters
* refactor: refine typed query context APIs
* fix: satisfy forbidden API checks
* fix: preserve invalid SET error semantics
* fix: normalize query context validation errors
* style: fix query context javadoc indentation
* fix: clarify query context range validation message
* refactor: extract query context parameter validation
* refactor: tighten query context parameter semantics
- Treat null as unset consistently across typed readers and writers.
- Parse integer/long parameters exactly; overflow reports the parameter's
Range bounds instead of silently truncating.
- Report all invalid values with BadQueryContextException and clearer
"Query context parameter [x] ..." messages.
- Build the parameter catalog lazily via a memoized Supplier.
- Add typed context(param, value) to all Druids builders.
- Remove QueryContextBuilder.putRaw and revert CachingClusteredClient;
putAll rejects null keys/values like ImmutableMap.Builder.
- Restore removed public QueryContext/QueryContexts APIs as deprecated.
* refactor: remove deprecated QueryContext accessors
* refactor: remove unused typed context methods from Druids builders
* test: remove redundant query context parameter tests
- Drop tests that duplicate behavior covered elsewhere (later-wins,
typed put validation, explicit null defaults, integer overflow and
error messages repeated across layers, duplicate SQL and HTTP checks).
- Consolidate integer parsing and set() tests, and move parser error
messages to QueryContextParameterTest.
- Make the ScanQuery test validate maxRowsQueuedForOrdering through
ScanQuery itself rather than through typed overrides.
---
docs/querying/query-context-reference.md | 2 +-
docs/querying/scan-query.md | 2 +-
.../query/QueryRetryOnMissingSegmentsTest.java | 3 +-
processing/pom.xml | 22 ++
.../main/java/org/apache/druid/query/Query.java | 21 +-
.../java/org/apache/druid/query/QueryContext.java | 237 ++++++++++--
.../apache/druid/query/QueryContextBuilder.java | 86 +++++
.../java/org/apache/druid/query/QueryContexts.java | 32 +-
.../druid/query/context/QueryContextParameter.java | 413 +++++++++++++++++++++
.../context/QueryContextParameterValidator.java | 57 +++
.../query/context/QueryContextParameters.java | 197 ++++++++++
.../context/constraint/ParameterConstraint.java | 32 ++
.../druid/query/context/constraint/Range.java | 106 ++++++
.../query/context/docs/ParameterDocumentation.java | 203 ++++++++++
.../docs/ParameterDocumentationGenerator.java | 249 +++++++++++++
.../org/apache/druid/query/scan/ScanQuery.java | 9 +-
.../apache/druid/query/scan/ScanQueryConfig.java | 1 -
.../druid/query/QueryContextBuilderTest.java | 84 +++++
.../org/apache/druid/query/QueryContextTest.java | 118 +++++-
.../query/context/QueryContextParameterTest.java | 320 ++++++++++++++++
.../QueryContextParameterValidatorTest.java | 53 +++
.../docs/ParameterDocumentationGeneratorTest.java | 143 +++++++
.../query/scan/ScanQueryResultOrderingTest.java | 8 +-
.../org/apache/druid/query/scan/ScanQueryTest.java | 26 ++
.../java/org/apache/druid/sql/SqlQueryPlus.java | 9 +
.../org/apache/druid/sql/http/SqlResource.java | 2 +-
.../org/apache/druid/sql/SqlQueryPlusTest.java | 48 +++
.../org/apache/druid/sql/http/SqlResourceTest.java | 16 +
28 files changed, 2435 insertions(+), 64 deletions(-)
diff --git a/docs/querying/query-context-reference.md
b/docs/querying/query-context-reference.md
index 3006869f175..247f80ef797 100644
--- a/docs/querying/query-context-reference.md
+++ b/docs/querying/query-context-reference.md
@@ -51,7 +51,7 @@ Unless otherwise noted, the following parameters apply to all
query types, and t
|`brokerService` | `null` | Broker service
to which this query should be routed. This parameter is honored only by a
broker selector strategy of type *manual*. See [Router
strategies](../design/router.md#router-strategies) for more details.|
|`useCache` | `true` | Flag indicating
whether to leverage the query cache for this query. When set to false, it
disables reading from the query cache for this query. When set to true, Apache
Druid uses `druid.broker.cache.useCache` or `druid.historical.cache.useCache`
to determine whether or not to read from the query cache |
|`populateCache` | `true` | Flag indicating
whether to save the results of the query to the query cache. Primarily used for
debugging. When set to false, it disables saving the results of this query to
the query cache. When set to true, Druid uses
`druid.broker.cache.populateCache` or `druid.historical.cache.populateCache` to
determine whether or not to save the results of this query to the query cache |
-|`useResultLevelCache`| `true` | Flag indicating whether
to leverage the result level cache for this query. When set to false, it
disables reading from the query cache for this query. When set to true, Druid
uses `druid.broker.cache.useResultLevelCache` to determine whether or not to
read from the result-level query cache |
+|`useResultLevelCache`| `true` | Flag indicating whether to leverage the
result level cache for this query. When set to false, it disables reading from
the query cache for this query. When set to true, Druid uses
`druid.broker.cache.useResultLevelCache` to determine whether or not to read
from the result-level query cache.| <!-- GENERATED QUERY CONTEXT PARAMETER:
useResultLevelCache -->
|`populateResultLevelCache` | `true` | Flag indicating
whether to save the results of the query to the result level cache. Primarily
used for debugging. When set to false, it disables saving the results of this
query to the query cache. When set to true, Druid uses
`druid.broker.cache.populateResultLevelCache` to determine whether or not to
save the results of this query to the result-level query cache |
|`bySegment` | `false` | Native queries
only. Return "by segment" results. Primarily used for debugging, setting it to
`true` returns results associated with the data segment they came from |
|`finalize` | `N/A` | Flag indicating
whether to "finalize" aggregation results. Primarily used for debugging. For
instance, the `hyperUnique` aggregator returns the full HyperLogLog sketch
instead of the estimated cardinality when this flag is set to `false` |
diff --git a/docs/querying/scan-query.md b/docs/querying/scan-query.md
index 693e951e28b..eac05398a98 100644
--- a/docs/querying/scan-query.md
+++ b/docs/querying/scan-query.md
@@ -197,7 +197,7 @@ Configuration properties:
|property|description|values|default|
|--------|-----------|------|-------|
-|maxRowsQueuedForOrdering|The maximum number of rows returned when time
ordering is used. Overrides the identically named config.|An integer in [1,
2147483647]|`druid.query.scan.maxRowsQueuedForOrdering`|
+|maxRowsQueuedForOrdering|The maximum number of rows returned when time
ordering is used. Overrides the identically named config.|An integer in [1,
2147483647]|`druid.query.scan.maxRowsQueuedForOrdering`| <!-- GENERATED QUERY
CONTEXT PARAMETER: maxRowsQueuedForOrdering -->
|maxSegmentPartitionsOrderedInMemory|The maximum number of segments scanned
per historical when time ordering is used. Overrides the identically named
config.|An integer in [1,
2147483647]|`druid.query.scan.maxSegmentPartitionsOrderedInMemory`|
Sample query context JSON object:
diff --git
a/embedded-tests/src/test/java/org/apache/druid/testing/embedded/query/QueryRetryOnMissingSegmentsTest.java
b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/query/QueryRetryOnMissingSegmentsTest.java
index 7b36b52bc4a..da115321347 100644
---
a/embedded-tests/src/test/java/org/apache/druid/testing/embedded/query/QueryRetryOnMissingSegmentsTest.java
+++
b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/query/QueryRetryOnMissingSegmentsTest.java
@@ -24,6 +24,7 @@ import org.apache.druid.java.util.common.ISE;
import org.apache.druid.java.util.common.StringUtils;
import org.apache.druid.java.util.common.jackson.JacksonUtils;
import org.apache.druid.query.QueryContexts;
+import org.apache.druid.query.context.QueryContextParameters;
import org.apache.druid.query.http.ClientSqlQuery;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
@@ -197,7 +198,7 @@ public class QueryRetryOnMissingSegmentsTest extends
QueryTestBase
final Map<String, Object> context = new HashMap<>();
// Disable cache so that each run hits historical.
context.put(QueryContexts.USE_CACHE_KEY, false);
- context.put(QueryContexts.USE_RESULT_LEVEL_CACHE_KEY, false);
+ QueryContextParameters.USE_RESULT_LEVEL_CACHE.set(context, false);
context.put(QueryContexts.NUM_RETRIES_ON_MISSING_SEGMENTS_KEY,
numRetriesOnMissingSegments);
context.put(QueryContexts.RETURN_PARTIAL_RESULTS_KEY, allowPartialResults);
context.put(QUERY_RETRY_TEST_CONTEXT_KEY, true);
diff --git a/processing/pom.xml b/processing/pom.xml
index c9564323c5c..c2e90625f88 100644
--- a/processing/pom.xml
+++ b/processing/pom.xml
@@ -34,6 +34,7 @@
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+ <query.context.docs.mode>verify</query.context.docs.mode>
<oshi.version>7.6.1</oshi.version>
<mariadb.legacy.version>2.7.3</mariadb.legacy.version>
</properties>
@@ -501,6 +502,27 @@
</execution>
</executions>
</plugin>
+ <plugin>
+ <groupId>org.codehaus.mojo</groupId>
+ <artifactId>exec-maven-plugin</artifactId>
+ <executions>
+ <execution>
+ <id>generate-query-context-documentation</id>
+ <phase>process-classes</phase>
+ <goals>
+ <goal>java</goal>
+ </goals>
+ <configuration>
+
<mainClass>org.apache.druid.query.context.docs.ParameterDocumentationGenerator</mainClass>
+ <arguments>
+ <argument>${project.basedir}/..</argument>
+ <argument>${query.context.docs.mode}</argument>
+ <argument>${project.build.directory}/generated-docs</argument>
+ </arguments>
+ </configuration>
+ </execution>
+ </executions>
+ </plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
diff --git a/processing/src/main/java/org/apache/druid/query/Query.java
b/processing/src/main/java/org/apache/druid/query/Query.java
index 635ead34e71..5e02b017785 100644
--- a/processing/src/main/java/org/apache/druid/query/Query.java
+++ b/processing/src/main/java/org/apache/druid/query/Query.java
@@ -28,6 +28,7 @@ import org.apache.druid.error.DruidException;
import org.apache.druid.guice.annotations.ExtensionPoint;
import org.apache.druid.java.util.common.HumanReadableBytes;
import org.apache.druid.java.util.common.granularity.Granularity;
+import org.apache.druid.query.context.QueryContextParameter;
import org.apache.druid.query.datasourcemetadata.DataSourceMetadataQuery;
import org.apache.druid.query.filter.DimFilter;
import org.apache.druid.query.groupby.GroupByQuery;
@@ -51,6 +52,7 @@ import org.joda.time.Duration;
import org.joda.time.Interval;
import javax.annotation.Nullable;
+import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Set;
@@ -132,7 +134,7 @@ public interface Query<T>
* {@link QueryContext#getString(String)} <br/>
* {@link QueryContext#getInt(String)} <br/>
* {@link QueryContext#getLong(String)} <br/>
- * {@link QueryContext#getFloat(String)} <br/>
+ * {@link QueryContext#getFloat(String, float)} <br/>
* {@link QueryContext#getEnum(String, Class, Enum)} <br/>
* {@link QueryContext#getHumanReadableBytes(String, HumanReadableBytes)}
*
@@ -186,6 +188,23 @@ public interface Query<T>
Query<T> withOverriddenContext(Map<String, Object> contextOverride);
+ /**
+ * Returns a new query with the given typed context parameter overridden.
Because {@link #withOverriddenContext(Map)}
+ * can only add or replace keys, a {@code null} value is stored as an
explicit {@code null}, which
+ * {@link QueryContext} treats the same as an unset parameter.
+ *
+ * @throws BadQueryContextException if the value is invalid
+ */
+ default <V> Query<T> withOverriddenContext(
+ final QueryContextParameter<V> parameter,
+ @Nullable final V value
+ )
+ {
+ return withOverriddenContext(
+ Collections.singletonMap(parameter.getName(),
parameter.validate(value))
+ );
+ }
+
/**
* Returns a new query, identical to this one, but with a different
associated {@link QuerySegmentSpec}.
* <p>
diff --git a/processing/src/main/java/org/apache/druid/query/QueryContext.java
b/processing/src/main/java/org/apache/druid/query/QueryContext.java
index c2929626002..dcb9b4f4319 100644
--- a/processing/src/main/java/org/apache/druid/query/QueryContext.java
+++ b/processing/src/main/java/org/apache/druid/query/QueryContext.java
@@ -25,16 +25,18 @@ import com.fasterxml.jackson.annotation.JsonInclude.Include;
import com.fasterxml.jackson.annotation.JsonValue;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.druid.java.util.common.HumanReadableBytes;
+import org.apache.druid.java.util.common.ISE;
import org.apache.druid.java.util.common.StringUtils;
import org.apache.druid.java.util.common.granularity.Granularity;
import org.apache.druid.query.QueryContexts.RealtimeSegmentsMode;
import org.apache.druid.query.QueryContexts.Vectorize;
+import org.apache.druid.query.context.QueryContextParameter;
+import org.apache.druid.query.context.QueryContextParameters;
import org.apache.druid.query.filter.InDimFilter;
import org.apache.druid.query.filter.TypedInFilter;
import javax.annotation.Nullable;
import java.io.IOException;
-import java.time.Duration;
import java.util.Collections;
import java.util.Map;
import java.util.Objects;
@@ -81,11 +83,152 @@ public class QueryContext
return EMPTY;
}
+ /**
+ * Creates a builder for a query context map.
+ */
+ public static QueryContextBuilder builder()
+ {
+ return new QueryContextBuilder();
+ }
+
public static QueryContext of(Map<String, Object> context)
{
return new QueryContext(context);
}
+ /**
+ * Creates a query context from one declared query context parameter.
+ */
+ public static <T> QueryContext of(
+ final QueryContextParameter<T> parameter,
+ @Nullable final T value
+ )
+ {
+ return new QueryContext(ofMap(parameter, value));
+ }
+
+ /**
+ * Creates a query context map from one declared query context parameter.
+ */
+ public static <T> Map<String, Object> ofMap(
+ final QueryContextParameter<T> parameter,
+ @Nullable final T value
+ )
+ {
+ return builder().put(parameter, value).toMap();
+ }
+
+ /**
+ * Creates a query context from two declared query context parameters.
+ */
+ public static <T1, T2> QueryContext of(
+ final QueryContextParameter<T1> parameter1,
+ @Nullable final T1 value1,
+ final QueryContextParameter<T2> parameter2,
+ @Nullable final T2 value2
+ )
+ {
+ return new QueryContext(ofMap(parameter1, value1, parameter2, value2));
+ }
+
+ /**
+ * Creates a query context map from two declared query context parameters.
+ */
+ public static <T1, T2> Map<String, Object> ofMap(
+ final QueryContextParameter<T1> parameter1,
+ @Nullable final T1 value1,
+ final QueryContextParameter<T2> parameter2,
+ @Nullable final T2 value2
+ )
+ {
+ return builder()
+ .put(parameter1, value1)
+ .put(parameter2, value2)
+ .toMap();
+ }
+
+ /**
+ * Creates a query context from three declared query context parameters.
+ */
+ public static <T1, T2, T3> QueryContext of(
+ final QueryContextParameter<T1> parameter1,
+ @Nullable final T1 value1,
+ final QueryContextParameter<T2> parameter2,
+ @Nullable final T2 value2,
+ final QueryContextParameter<T3> parameter3,
+ @Nullable final T3 value3
+ )
+ {
+ return new QueryContext(ofMap(parameter1, value1, parameter2, value2,
parameter3, value3));
+ }
+
+ /**
+ * Creates a query context map from three declared query context parameters.
+ */
+ public static <T1, T2, T3> Map<String, Object> ofMap(
+ final QueryContextParameter<T1> parameter1,
+ @Nullable final T1 value1,
+ final QueryContextParameter<T2> parameter2,
+ @Nullable final T2 value2,
+ final QueryContextParameter<T3> parameter3,
+ @Nullable final T3 value3
+ )
+ {
+ return builder()
+ .put(parameter1, value1)
+ .put(parameter2, value2)
+ .put(parameter3, value3)
+ .toMap();
+ }
+
+ /**
+ * Creates a query context from four declared query context parameters.
+ */
+ public static <T1, T2, T3, T4> QueryContext of(
+ final QueryContextParameter<T1> parameter1,
+ @Nullable final T1 value1,
+ final QueryContextParameter<T2> parameter2,
+ @Nullable final T2 value2,
+ final QueryContextParameter<T3> parameter3,
+ @Nullable final T3 value3,
+ final QueryContextParameter<T4> parameter4,
+ @Nullable final T4 value4
+ )
+ {
+ return new QueryContext(ofMap(
+ parameter1,
+ value1,
+ parameter2,
+ value2,
+ parameter3,
+ value3,
+ parameter4,
+ value4
+ ));
+ }
+
+ /**
+ * Creates a query context map from four declared query context parameters.
+ */
+ public static <T1, T2, T3, T4> Map<String, Object> ofMap(
+ final QueryContextParameter<T1> parameter1,
+ @Nullable final T1 value1,
+ final QueryContextParameter<T2> parameter2,
+ @Nullable final T2 value2,
+ final QueryContextParameter<T3> parameter3,
+ @Nullable final T3 value3,
+ final QueryContextParameter<T4> parameter4,
+ @Nullable final T4 value4
+ )
+ {
+ return builder()
+ .put(parameter1, value1)
+ .put(parameter2, value2)
+ .put(parameter3, value3)
+ .put(parameter4, value4)
+ .toMap();
+ }
+
public boolean isEmpty()
{
return context.isEmpty();
@@ -106,6 +249,15 @@ public class QueryContext
return context.containsKey(key);
}
+ /**
+ * Check if the given declared query context parameter is set to a non-null
value. An explicit {@code null} is
+ * treated the same as an absent key.
+ */
+ public boolean has(final QueryContextParameter<?> parameter)
+ {
+ return get(parameter.getName()) != null;
+ }
+
/**
* Return a value as a generic {@code Object}, returning {@code null} if the
* context value is not set.
@@ -116,6 +268,55 @@ public class QueryContext
return context.get(key);
}
+ /**
+ * Returns the parsed parameter value. If the key is absent or explicitly
{@code null}, returns the declared default,
+ * or {@code null} if the parameter has no declared default.
+ *
+ * @throws BadQueryContextException if the value is invalid
+ */
+ @Nullable
+ public <T> T get(final QueryContextParameter<T> parameter)
+ {
+ return parseOrDefault(parameter, parameter.getDefaultValue().orElse(null));
+ }
+
+ /**
+ * Returns the parsed parameter value. If the key is absent or explicitly
{@code null}, returns the declared default.
+ *
+ * @throws ISE if the parameter has no declared default
+ * @throws BadQueryContextException if the value is invalid
+ */
+ public <T> T getOrDefault(final QueryContextParameter<T> parameter)
+ {
+ return getOrDefault(
+ parameter,
+ parameter.getDefaultValue().orElseThrow(
+ () -> new ISE("Query context parameter [%s] has no declared
default", parameter.getName())
+ )
+ );
+ }
+
+ /**
+ * Returns the parsed parameter value. If the key is absent or explicitly
{@code null}, returns the supplied default.
+ *
+ * @throws BadQueryContextException if the value is invalid
+ */
+ public <T> T getOrDefault(final QueryContextParameter<T> parameter, final T
defaultValue)
+ {
+ return parseOrDefault(parameter, defaultValue);
+ }
+
+ @Nullable
+ private <T> T parseOrDefault(final QueryContextParameter<T> parameter,
@Nullable final T defaultValue)
+ {
+ if (!containsKey(parameter.getName())) {
+ return defaultValue;
+ }
+ // Parse even an explicit null so that non-nullable parameters reject it.
+ final T parsed = parameter.parse(get(parameter.getName()));
+ return parsed == null ? defaultValue : parsed;
+ }
+
/**
* Return a value as an {@code String}, returning {@link null} if the
* context value is not set.
@@ -199,18 +400,6 @@ public class QueryContext
return QueryContexts.parseLong(context, key, defaultValue);
}
- /**
- * Return a value as an {@code Float}, returning {@link null} if the
- * context value is not set.
- *
- * @throws BadQueryContextException for an invalid value
- */
- @SuppressWarnings("unused")
- public Float getFloat(final String key)
- {
- return QueryContexts.getAsFloat(key, get(key));
- }
-
/**
* Return a value as an {@code float}, returning the default value if the
* context value is not set.
@@ -299,12 +488,12 @@ public class QueryContext
public boolean isUseResultLevelCache()
{
- return isUseResultLevelCache(QueryContexts.DEFAULT_USE_RESULTLEVEL_CACHE);
+ return getOrDefault(QueryContextParameters.USE_RESULT_LEVEL_CACHE);
}
public boolean isUseResultLevelCache(boolean defaultValue)
{
- return getBoolean(QueryContexts.USE_RESULT_LEVEL_CACHE_KEY, defaultValue);
+ return getOrDefault(QueryContextParameters.USE_RESULT_LEVEL_CACHE,
defaultValue);
}
public boolean isFinalize(boolean defaultValue)
@@ -514,15 +703,6 @@ public class QueryContext
);
}
- @Nullable
- public Duration getTimeoutDuration()
- {
- if (hasTimeout()) {
- return Duration.ofMillis(getTimeout());
- }
- return null;
- }
-
public long getDefaultTimeout()
{
final long defaultTimeout = getLong(QueryContexts.DEFAULT_TIMEOUT_KEY,
QueryContexts.DEFAULT_TIMEOUT_MILLIS);
@@ -817,13 +997,4 @@ public class QueryContext
}
return QueryContexts.DEFAULT_REALTIME_SEGMENTS_MODE;
}
-
- /**
- * @deprecated Use {@link #getRealtimeSegmentsMode()} instead.
- */
- @Deprecated
- public boolean isRealtimeSegmentsOnly()
- {
- return getRealtimeSegmentsMode() == RealtimeSegmentsMode.EXCLUSIVE;
- }
}
diff --git
a/processing/src/main/java/org/apache/druid/query/QueryContextBuilder.java
b/processing/src/main/java/org/apache/druid/query/QueryContextBuilder.java
new file mode 100644
index 00000000000..25b4bbdf927
--- /dev/null
+++ b/processing/src/main/java/org/apache/druid/query/QueryContextBuilder.java
@@ -0,0 +1,86 @@
+/*
+ * 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;
+
+import org.apache.druid.query.context.QueryContextParameter;
+
+import javax.annotation.Nullable;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Objects;
+
+/**
+ * Builds an immutable query context map using typed parameter descriptors.
+ * Existing string-keyed maps can be copied with {@link #putAll(Map)} for
backward compatibility.
+ *
+ * <p>Like {@link com.google.common.collect.ImmutableMap.Builder}, {@link
#putAll(Map)} rejects {@code null} keys and
+ * values.
+ * Typed entries follow the {@link QueryContextParameter} convention that
{@code null} means "unset", so
+ * {@link #put(QueryContextParameter, Object)} with a {@code null} value
removes the parameter.
+ * Unlike {@code ImmutableMap.Builder}, a later value for the same key
replaces the earlier one.</p>
+ */
+public final class QueryContextBuilder
+{
+ private final Map<String, Object> values = new LinkedHashMap<>();
+
+ /**
+ * Adds all values from an existing query context map.
+ *
+ * @throws NullPointerException if any key or value is {@code null}
+ */
+ public QueryContextBuilder putAll(final Map<? extends String, ?> values)
+ {
+ values.forEach(
+ (name, value) -> this.values.put(
+ Objects.requireNonNull(name, "name"),
+ Objects.requireNonNull(value, () -> "value for query context key
[" + name + "]")
+ )
+ );
+ return this;
+ }
+
+ /**
+ * Adds a context value using a typed parameter descriptor. A {@code null}
value removes the parameter.
+ *
+ * @throws BadQueryContextException if the value is invalid
+ */
+ public <V> QueryContextBuilder put(final QueryContextParameter<V> parameter,
@Nullable final V value)
+ {
+ parameter.set(values, value);
+ return this;
+ }
+
+ /**
+ * Converts the current values to an immutable context map snapshot.
+ */
+ public Map<String, Object> toMap()
+ {
+ return Collections.unmodifiableMap(new LinkedHashMap<>(values));
+ }
+
+ /**
+ * Converts the current values to an immutable {@link QueryContext} snapshot.
+ */
+ public QueryContext toContext()
+ {
+ return QueryContext.of(toMap());
+ }
+}
diff --git a/processing/src/main/java/org/apache/druid/query/QueryContexts.java
b/processing/src/main/java/org/apache/druid/query/QueryContexts.java
index 4494b472e47..2c2204a8683 100644
--- a/processing/src/main/java/org/apache/druid/query/QueryContexts.java
+++ b/processing/src/main/java/org/apache/druid/query/QueryContexts.java
@@ -28,6 +28,7 @@ import org.apache.druid.java.util.common.ISE;
import org.apache.druid.java.util.common.Numbers;
import org.apache.druid.java.util.common.StringUtils;
import org.apache.druid.query.aggregation.AggregatorFactory;
+import org.apache.druid.query.context.QueryContextParameter;
import javax.annotation.Nullable;
import java.math.BigDecimal;
@@ -92,6 +93,10 @@ public class QueryContexts
public static final String TIME_BOUNDARY_PLANNING_KEY =
"enableTimeBoundaryPlanning";
public static final String POPULATE_CACHE_KEY = "populateCache";
public static final String POPULATE_RESULT_LEVEL_CACHE_KEY =
"populateResultLevelCache";
+ /**
+ * @deprecated Use {@link
org.apache.druid.query.context.QueryContextParameters#USE_RESULT_LEVEL_CACHE}
instead.
+ */
+ @Deprecated
public static final String USE_RESULT_LEVEL_CACHE_KEY =
"useResultLevelCache";
public static final String SERIALIZE_DATE_TIME_AS_LONG_KEY =
"serializeDateTimeAsLong";
public static final String SERIALIZE_DATE_TIME_AS_LONG_INNER_KEY =
"serializeDateTimeAsLongInner";
@@ -168,6 +173,12 @@ public class QueryContexts
public static final boolean DEFAULT_POPULATE_CACHE = true;
public static final boolean DEFAULT_USE_CACHE = true;
public static final boolean DEFAULT_POPULATE_RESULTLEVEL_CACHE = true;
+ /**
+ * @deprecated Use the declared default of
+ * {@link
org.apache.druid.query.context.QueryContextParameters#USE_RESULT_LEVEL_CACHE}
instead, for example
+ * {@link QueryContext#getOrDefault(QueryContextParameter)}.
+ */
+ @Deprecated
public static final boolean DEFAULT_USE_RESULTLEVEL_CACHE = true;
public static final Vectorize DEFAULT_VECTORIZE = Vectorize.TRUE;
public static final Vectorize DEFAULT_VECTORIZE_VIRTUAL_COLUMN =
Vectorize.TRUE;
@@ -516,6 +527,23 @@ public class QueryContexts
return overridden;
}
+ /**
+ * Insert, update or remove a typed parameter to produce an overridden
context. A {@code null} value removes the
+ * parameter. Leaves the original context unchanged.
+ *
+ * @throws BadQueryContextException if the value is invalid
+ */
+ public static <V> Map<String, Object> override(
+ @Nullable final Map<String, Object> context,
+ final QueryContextParameter<V> parameter,
+ @Nullable final V value
+ )
+ {
+ final Map<String, Object> overridden = context == null ? new HashMap<>() :
new HashMap<>(context);
+ parameter.set(overridden, value);
+ return overridden;
+ }
+
/**
* Insert or replace multiple keys to produce an overridden context.
* Leaves the original context unchanged.
@@ -559,7 +587,9 @@ public class QueryContexts
}
try {
- if (value instanceof String) {
+ if (clazz.isInstance(value)) {
+ return clazz.cast(value);
+ } else if (value instanceof String) {
return Enum.valueOf(clazz, StringUtils.toUpperCase((String) value));
} else if (value instanceof Boolean) {
return Enum.valueOf(clazz,
StringUtils.toUpperCase(String.valueOf(value)));
diff --git
a/processing/src/main/java/org/apache/druid/query/context/QueryContextParameter.java
b/processing/src/main/java/org/apache/druid/query/context/QueryContextParameter.java
new file mode 100644
index 00000000000..5c8c3096b40
--- /dev/null
+++
b/processing/src/main/java/org/apache/druid/query/context/QueryContextParameter.java
@@ -0,0 +1,413 @@
+/*
+ * 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.context;
+
+import org.apache.druid.java.util.common.IAE;
+import org.apache.druid.java.util.common.ISE;
+import org.apache.druid.java.util.common.StringUtils;
+import org.apache.druid.query.BadQueryContextException;
+import org.apache.druid.query.context.constraint.ParameterConstraint;
+import org.apache.druid.query.context.constraint.Range;
+import org.apache.druid.query.context.docs.ParameterDocumentation;
+
+import javax.annotation.Nullable;
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.function.Function;
+
+/**
+ * Describes a query context parameter without changing how existing query
code reads the context. Instances are
+ * immutable and thread-safe when their parser and constraint implementations
are thread-safe.
+ *
+ * <p>A {@code null} value means "unset": readers treat an explicit {@code
null} the same as an absent key, and writers
+ * remove the key (or store {@code null} where removal is not possible).
{@link #isNullable()} controls whether a
+ * {@code null} may be supplied at all. Invalid values are reported with
{@link BadQueryContextException}.</p>
+ *
+ * @param <T> parsed value type
+ */
+public final class QueryContextParameter<T>
+{
+ @FunctionalInterface
+ public interface ValueParser<T>
+ {
+ /**
+ * Converts a raw context value into the parameter type. Values that are
already instances of the parameter's value
+ * type bypass the parser, so implementations only need to handle values
that require coercion, such as strings.
+ * {@code null} values are handled before the parser is invoked.
Implementations must be thread-safe and must throw
+ * {@link BadQueryContextException} for values that cannot be converted.
+ */
+ @Nullable
+ T parse(Object value);
+ }
+
+ /**
+ * Thrown by {@link #exactNumber} for a whole number that does not fit the
value type. {@link #parse(Object)}
+ * converts it to a {@link BadQueryContextException} that quotes the
parameter's {@link Range} bounds when declared.
+ */
+ private static final class OutOfTypeRangeException extends RuntimeException
+ {
+ private final Object value;
+ private final long min;
+ private final long max;
+
+ OutOfTypeRangeException(final Object value, final long min, final long max)
+ {
+ super(null, null, false, false);
+ this.value = value;
+ this.min = min;
+ this.max = max;
+ }
+ }
+
+ private final String name;
+ private final Class<T> valueType;
+ private final ValueParser<T> parser;
+ private final List<ParameterConstraint<T>> constraints;
+ private final Optional<T> defaultValue;
+ private final boolean nullable;
+ private final Optional<String> deprecationMessage;
+ private final Optional<ParameterDocumentation> documentation;
+
+ private QueryContextParameter(final Builder<T> builder)
+ {
+ this.name = builder.name;
+ this.valueType = builder.valueType;
+ this.parser = builder.parser;
+ this.constraints = List.copyOf(builder.constraints);
+ this.defaultValue = Optional.ofNullable(builder.defaultValue);
+ this.nullable = builder.nullable;
+ this.deprecationMessage = Optional.ofNullable(builder.deprecationMessage);
+ this.documentation = builder.documentationBuilder == null
+ ? Optional.empty()
+ : Optional.of(builder.documentationBuilder.build());
+
+ if (defaultValue.isPresent()) {
+ try {
+ validate(defaultValue.get());
+ }
+ catch (BadQueryContextException e) {
+ // An invalid declared default is a programming error, not a bad
user-supplied value.
+ throw new IAE(e, "Invalid default value for query context parameter
[%s]: %s", name, e.getMessage());
+ }
+ }
+ }
+
+ public static <T> Builder<T> builder(
+ final String name,
+ final Class<T> valueType,
+ final ValueParser<T> parser
+ )
+ {
+ return new Builder<>(name, valueType, parser);
+ }
+
+ public String getName()
+ {
+ return name;
+ }
+
+ public Class<T> getValueType()
+ {
+ return valueType;
+ }
+
+ /**
+ * Sets this parameter in a mutable query context map. A {@code null} value
removes the key.
+ *
+ * @throws BadQueryContextException if the value is invalid
+ */
+ public void set(final Map<String, Object> context, @Nullable final T value)
+ {
+ if (validate(value) == null) {
+ context.remove(name);
+ } else {
+ context.put(name, value);
+ }
+ }
+
+ /**
+ * Converts a raw context value, such as one supplied in a JSON request or a
SQL {@code SET} statement, into the
+ * parameter type and validates it. The declared default is not applied.
+ *
+ * @return the parsed value, or {@code null} if the raw value is {@code null}
+ * @throws BadQueryContextException if the value cannot be converted or
violates a constraint
+ */
+ @Nullable
+ public T parse(@Nullable final Object value)
+ {
+ if (value == null) {
+ return validate(null);
+ }
+ if (valueType.isInstance(value)) {
+ return validate(valueType.cast(value));
+ }
+ final T parsed;
+ try {
+ parsed = parser.parse(value);
+ }
+ catch (OutOfTypeRangeException e) {
+ throw outOfRangeException(e);
+ }
+ return validate(parsed);
+ }
+
+ /**
+ * Reports a value that does not fit the value type using the parameter's
own {@link Range} bounds when declared, so
+ * users see the same bounds regardless of whether the value overflowed the
type or only violated the constraint.
+ */
+ private BadQueryContextException outOfRangeException(final
OutOfTypeRangeException e)
+ {
+ for (final ParameterConstraint<T> constraint : constraints) {
+ if (constraint instanceof Range.Constraint<T> range) {
+ return Range.outOfRangeException(name, range.getLowerBound(),
range.getUpperBound(), e.value);
+ }
+ }
+ return Range.outOfRangeException(name, e.min, e.max, e.value);
+ }
+
+ /**
+ * Parses a raw value and returns the declared default when parsing produces
{@code null}.
+ *
+ * @throws ISE if this parameter has no declared default
+ */
+ public T parseOrDefault(@Nullable final Object value)
+ {
+ final T parsed = parse(value);
+ if (parsed != null) {
+ return parsed;
+ }
+ return defaultValue.orElseThrow(
+ () -> new ISE("Query context parameter [%s] has no declared default",
name)
+ );
+ }
+
+ /**
+ * Validates and returns an already-typed value without invoking the parser.
+ *
+ * @throws BadQueryContextException if the value is {@code null} for a
non-nullable parameter or violates a constraint
+ */
+ @Nullable
+ public T validate(@Nullable final T value)
+ {
+ if (value == null) {
+ if (!nullable) {
+ throw new BadQueryContextException(
+ StringUtils.format("Query context parameter [%s] must not be
null", name)
+ );
+ }
+ return null;
+ }
+ constraints.forEach(constraint -> constraint.validate(name, value));
+ return value;
+ }
+
+ public List<ParameterConstraint<T>> getConstraints()
+ {
+ return constraints;
+ }
+
+ /**
+ * Returns the declared default, if one exists. Parameters whose fallback is
supplied by runtime configuration or an
+ * individual call site do not have a default value in the descriptor.
+ */
+ public Optional<T> getDefaultValue()
+ {
+ return defaultValue;
+ }
+
+ public boolean isNullable()
+ {
+ return nullable;
+ }
+
+ public boolean isDeprecated()
+ {
+ return deprecationMessage.isPresent();
+ }
+
+ public Optional<String> getDeprecationMessage()
+ {
+ return deprecationMessage;
+ }
+
+ public Optional<ParameterDocumentation> getDocumentation()
+ {
+ return documentation;
+ }
+
+ @Override
+ public String toString()
+ {
+ return name;
+ }
+
+ /**
+ * Converts a number or numeric string without silent truncation or
overflow, unlike {@link Number#intValue()} and
+ * {@link Number#longValue()}. Integral values with a zero fractional part,
such as {@code 12.0} or {@code "12.00"},
+ * are accepted. Whole numbers outside {@code [min, max]} are reported
through
+ * {@link OutOfTypeRangeException} so the parameter can quote its own range
constraint.
+ */
+ static <T> T exactNumber(
+ final String name,
+ final Object value,
+ final String typeName,
+ final long min,
+ final long max,
+ final Function<BigDecimal, T> converter
+ )
+ {
+ final String expectedFormat = "in " + typeName + " format";
+ final BigDecimal decimal;
+ try {
+ if (value instanceof BigDecimal) {
+ decimal = (BigDecimal) value;
+ } else if (value instanceof Number || value instanceof String) {
+ decimal = new BigDecimal(value.toString());
+ } else {
+ throw QueryContextParameters.invalidValueException(name,
expectedFormat, value);
+ }
+ }
+ catch (NumberFormatException e) {
+ throw QueryContextParameters.invalidValueException(name, expectedFormat,
value);
+ }
+
+ if (decimal.signum() != 0 && decimal.stripTrailingZeros().scale() > 0) {
+ throw QueryContextParameters.invalidValueException(name, expectedFormat,
value);
+ }
+ if (decimal.compareTo(BigDecimal.valueOf(min)) < 0 ||
decimal.compareTo(BigDecimal.valueOf(max)) > 0) {
+ throw new OutOfTypeRangeException(value, min, max);
+ }
+ return converter.apply(decimal);
+ }
+
+ /** Not thread-safe. */
+ public static final class Builder<T>
+ {
+ private final String name;
+ private final Class<T> valueType;
+ private final ValueParser<T> parser;
+ private final List<ParameterConstraint<T>> constraints = new ArrayList<>();
+ @Nullable
+ private T defaultValue;
+ // Query context maps historically permit explicit null values, so
preserve that behavior unless declared otherwise.
+ private boolean nullable = true;
+ @Nullable
+ private String deprecationMessage;
+ @Nullable
+ private ParameterDocumentation.Builder documentationBuilder;
+
+ private Builder(final String name, final Class<T> valueType, final
ValueParser<T> parser)
+ {
+ this.name = Objects.requireNonNull(name, "name");
+ this.valueType = Objects.requireNonNull(valueType, "valueType");
+ this.parser = Objects.requireNonNull(parser, "parser");
+
+ if (name.isBlank() || !name.equals(name.trim())) {
+ throw new IAE("Query context parameter name [%s] must not be blank or
contain surrounding whitespace", name);
+ }
+ }
+
+ public Builder<T> constraint(final ParameterConstraint<T> constraint)
+ {
+ constraints.add(Objects.requireNonNull(constraint, "constraint"));
+ return this;
+ }
+
+ public Builder<T> defaultValue(final T defaultValue)
+ {
+ this.defaultValue = Objects.requireNonNull(defaultValue, "defaultValue");
+ return this;
+ }
+
+ public Builder<T> nullable(final boolean nullable)
+ {
+ this.nullable = nullable;
+ return this;
+ }
+
+ public Builder<T> deprecated(final String deprecationMessage)
+ {
+ this.deprecationMessage = Objects.requireNonNull(deprecationMessage,
"deprecationMessage");
+ if (deprecationMessage.isBlank()) {
+ throw new IAE("Query context parameter deprecation message must not be
blank");
+ }
+ return this;
+ }
+
+ private ParameterDocumentation.Builder documentationBuilder()
+ {
+ if (documentationBuilder == null) {
+ documentationBuilder = ParameterDocumentation.builder();
+ }
+ return documentationBuilder;
+ }
+
+ public Builder<T> description(final String description)
+ {
+ documentationBuilder().description(description);
+ return this;
+ }
+
+ public Builder<T> query(final ParameterDocumentation.Query... queries)
+ {
+ documentationBuilder().query(queries);
+ return this;
+ }
+
+ public Builder<T> engine(final ParameterDocumentation.Engine... engines)
+ {
+ documentationBuilder().engine(engines);
+ return this;
+ }
+
+ public Builder<T> queryType(final ParameterDocumentation.QueryType...
queryTypes)
+ {
+ documentationBuilder().queryType(queryTypes);
+ return this;
+ }
+
+ public Builder<T> statement(final ParameterDocumentation.StatementType...
statementTypes)
+ {
+ documentationBuilder().statement(statementTypes);
+ return this;
+ }
+
+ public Builder<T> defaultDescription(final String defaultDescription)
+ {
+ documentationBuilder().defaultDescription(defaultDescription);
+ return this;
+ }
+
+ public Builder<T> since(final String since)
+ {
+ documentationBuilder().since(since);
+ return this;
+ }
+
+ public QueryContextParameter<T> build()
+ {
+ return new QueryContextParameter<>(this);
+ }
+ }
+}
diff --git
a/processing/src/main/java/org/apache/druid/query/context/QueryContextParameterValidator.java
b/processing/src/main/java/org/apache/druid/query/context/QueryContextParameterValidator.java
new file mode 100644
index 00000000000..673d845f9ad
--- /dev/null
+++
b/processing/src/main/java/org/apache/druid/query/context/QueryContextParameterValidator.java
@@ -0,0 +1,57 @@
+/*
+ * 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.context;
+
+import org.apache.druid.query.BadQueryContextException;
+
+import javax.annotation.Nullable;
+import java.util.Map;
+
+/** Validates query context parameter values against the descriptor catalog. */
+public final class QueryContextParameterValidator
+{
+ private QueryContextParameterValidator()
+ {
+ }
+
+ /**
+ * Validates a value assigned by a SQL {@code SET} statement.
+ *
+ * @throws BadQueryContextException if the parameter is recognized and the
value is invalid
+ */
+ public static void validate(final String name, @Nullable final Object value)
+ {
+ final QueryContextParameter<?> parameter =
QueryContextParameters.ALL.get().get(name);
+ // Unmigrated parameters are intentionally accepted until the catalog
contains every supported context parameter.
+ if (parameter != null) {
+ parameter.parse(value);
+ }
+ }
+
+ /**
+ * Validates every recognized query context parameter in the supplied map.
+ *
+ * @throws BadQueryContextException if a recognized parameter has an invalid
value
+ */
+ public static void validate(final Map<String, Object> parameters)
+ {
+ parameters.forEach(QueryContextParameterValidator::validate);
+ }
+}
diff --git
a/processing/src/main/java/org/apache/druid/query/context/QueryContextParameters.java
b/processing/src/main/java/org/apache/druid/query/context/QueryContextParameters.java
new file mode 100644
index 00000000000..b356e3e39f9
--- /dev/null
+++
b/processing/src/main/java/org/apache/druid/query/context/QueryContextParameters.java
@@ -0,0 +1,197 @@
+/*
+ * 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.context;
+
+import com.google.common.base.Suppliers;
+import org.apache.druid.java.util.common.ISE;
+import org.apache.druid.java.util.common.StringUtils;
+import org.apache.druid.query.BadQueryContextException;
+import org.apache.druid.query.context.constraint.Range;
+import org.apache.druid.query.context.docs.ParameterDocumentation.Engine;
+import org.apache.druid.query.context.docs.ParameterDocumentation.Query;
+import org.apache.druid.query.context.docs.ParameterDocumentation.QueryType;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Modifier;
+import java.math.BigDecimal;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.function.Supplier;
+
+/// Central catalog of query context parameter descriptors.
+///
+/// Define user-facing metadata here, including descriptions, default values,
constraints,
+/// applicability, and introduction versions. The
+/// {@link
org.apache.druid.query.context.docs.ParameterDocumentationGenerator} generates
the
+/// corresponding tables in `query-context-reference.md`, `scan-query.md`, and
+/// `sql-query-context.md`.
+///
+/// When adding or changing a descriptor, including introducing a new
parameter, run this command
+/// from the repository root to regenerate the checked-in documentation. The
default Maven mode
+/// verifies that the generated documentation is up to date.
+///
+/// ```shell
+/// mvn -ntp -pl processing -am -Pskip-static-checks -Dweb.console.skip=true
-DskipTests -Dquery.context.docs.mode=generate -T1C process-classes
+/// ```
+public final class QueryContextParameters
+{
+ public static final QueryContextParameter<Boolean> USE_RESULT_LEVEL_CACHE =
booleanParameter("useResultLevelCache")
+ .defaultValue(true)
+ .description(
+ """
+ Flag indicating whether to leverage the result level cache for this
query.
+ When set to false, it disables reading from the query cache for this
query.
+ When set to true, Druid uses
`druid.broker.cache.useResultLevelCache` to determine whether or not to read
from the result-level query cache.
+ """
+ )
+ .since("0.13.0-incubating")
+ .query(Query.JSON, Query.SQL)
+ .engine(Engine.NATIVE)
+ .build();
+
+ public static final QueryContextParameter<Integer>
MAX_ROWS_QUEUED_FOR_ORDERING =
+ integerParameter("maxRowsQueuedForOrdering")
+ .constraint(Range.closedRange(1, Integer.MAX_VALUE))
+ .description(
+ """
+ The maximum number of rows returned when time ordering is used.
+ Overrides the identically named config.
+ """
+ )
+ .since("0.15.0-incubating")
+ .defaultDescription("`druid.query.scan.maxRowsQueuedForOrdering`")
+ .query(Query.JSON)
+ .engine(Engine.NATIVE)
+ .queryType(QueryType.SCAN)
+ .build();
+
+ private QueryContextParameters()
+ {
+ }
+
+ /**
+ * Immutable query context parameter descriptors indexed by parameter name.
Built lazily on first access so that
+ * every parameter field is initialized regardless of where it is declared
in this class.
+ */
+ public static final Supplier<Map<String, QueryContextParameter<?>>> ALL =
+ Suppliers.memoize(() -> {
+ final Map<String, QueryContextParameter<?>> byName = new
LinkedHashMap<>();
+ for (final Field field :
QueryContextParameters.class.getDeclaredFields()) {
+ if (!Modifier.isPublic(field.getModifiers())
+ || !Modifier.isStatic(field.getModifiers())
+ || !QueryContextParameter.class.equals(field.getType())) {
+ continue;
+ }
+
+ final QueryContextParameter<?> parameter;
+ try {
+ parameter = (QueryContextParameter<?>) field.get(null);
+ }
+ catch (final IllegalAccessException e) {
+ throw new ISE(e, "Unable to read query context parameter field
[%s]", field.getName());
+ }
+
+ final QueryContextParameter<?> existing =
byName.putIfAbsent(parameter.getName(), parameter);
+ if (existing != null) {
+ throw new ISE(
+ "Duplicate query context parameter [%s] declared by field
[%s]",
+ parameter.getName(),
+ field.getName()
+ );
+ }
+ }
+ return Collections.unmodifiableMap(byName);
+ });
+
+ static QueryContextParameter.Builder<Boolean> booleanParameter(final String
name)
+ {
+ return QueryContextParameter.builder(
+ name,
+ Boolean.class,
+ value -> {
+ if (value instanceof String) {
+ // Matches the established QueryContexts coercion: any string
other than "true" (ignoring case) is false.
+ return Boolean.parseBoolean((String) value);
+ }
+ throw invalidValueException(name, "a boolean", value);
+ }
+ );
+ }
+
+ static QueryContextParameter.Builder<Integer> integerParameter(final String
name)
+ {
+ return QueryContextParameter.builder(
+ name,
+ Integer.class,
+ value -> QueryContextParameter.exactNumber(
+ name,
+ value,
+ "integer",
+ Integer.MIN_VALUE,
+ Integer.MAX_VALUE,
+ BigDecimal::intValueExact
+ )
+ );
+ }
+
+ static QueryContextParameter.Builder<Long> longParameter(final String name)
+ {
+ return QueryContextParameter.builder(
+ name,
+ Long.class,
+ value -> QueryContextParameter.exactNumber(
+ name,
+ value,
+ "long",
+ Long.MIN_VALUE,
+ Long.MAX_VALUE,
+ BigDecimal::longValueExact
+ )
+ );
+ }
+
+ static QueryContextParameter.Builder<String> stringParameter(final String
name)
+ {
+ return QueryContextParameter.builder(
+ name,
+ String.class,
+ value -> {
+ throw invalidValueException(name, "a string", value);
+ }
+ );
+ }
+
+ /**
+ * Creates the exception that parsers throw for a value that cannot be
converted to the parameter type.
+ *
+ * @param expected description of the expected value that completes "should
be ...", such as "in integer format"
+ */
+ public static BadQueryContextException invalidValueException(
+ final String name,
+ final String expected,
+ final Object actual
+ )
+ {
+ return new BadQueryContextException(
+ StringUtils.format("Query context parameter [%s] should be %s, but got
[%s]", name, expected, actual)
+ );
+ }
+}
diff --git
a/processing/src/main/java/org/apache/druid/query/context/constraint/ParameterConstraint.java
b/processing/src/main/java/org/apache/druid/query/context/constraint/ParameterConstraint.java
new file mode 100644
index 00000000000..03b3a02bb62
--- /dev/null
+++
b/processing/src/main/java/org/apache/druid/query/context/constraint/ParameterConstraint.java
@@ -0,0 +1,32 @@
+/*
+ * 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.context.constraint;
+
+/**
+ * A structured, executable restriction on a non-null, parsed query context
parameter value. Nullability is handled by
+ * {@code QueryContextParameter} before constraints are invoked.
+ */
+public interface ParameterConstraint<T>
+{
+ /**
+ * @throws org.apache.druid.query.BadQueryContextException if the value
violates this constraint
+ */
+ void validate(String parameterName, T value);
+}
diff --git
a/processing/src/main/java/org/apache/druid/query/context/constraint/Range.java
b/processing/src/main/java/org/apache/druid/query/context/constraint/Range.java
new file mode 100644
index 00000000000..199a9096b67
--- /dev/null
+++
b/processing/src/main/java/org/apache/druid/query/context/constraint/Range.java
@@ -0,0 +1,106 @@
+/*
+ * 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.context.constraint;
+
+import org.apache.druid.java.util.common.IAE;
+import org.apache.druid.java.util.common.StringUtils;
+import org.apache.druid.query.BadQueryContextException;
+
+import java.util.Objects;
+
+/**
+ * Factory for range-based parameter constraints.
+ */
+public final class Range
+{
+ /**
+ * A range constraint whose bounds are available to documentation generators.
+ */
+ public interface Constraint<T> extends ParameterConstraint<T>
+ {
+ T getLowerBound();
+
+ T getUpperBound();
+ }
+
+ private Range()
+ {
+ }
+
+ /**
+ * Creates an inclusive range using the natural ordering of its bounds and
values.
+ */
+ public static <T extends Comparable<? super T>> Constraint<T> closedRange(
+ final T lowerBound,
+ final T upperBound
+ )
+ {
+ Objects.requireNonNull(lowerBound, "lowerBound");
+ Objects.requireNonNull(upperBound, "upperBound");
+ if (lowerBound.compareTo(upperBound) > 0) {
+ throw new IAE("Closed range lower bound [%s] must not exceed upper bound
[%s]", lowerBound, upperBound);
+ }
+
+ return new Constraint<>()
+ {
+ @Override
+ public T getLowerBound()
+ {
+ return lowerBound;
+ }
+
+ @Override
+ public T getUpperBound()
+ {
+ return upperBound;
+ }
+
+ @Override
+ public void validate(final String parameterName, final T value)
+ {
+ if (value.compareTo(lowerBound) < 0 || value.compareTo(upperBound) >
0) {
+ throw outOfRangeException(parameterName, lowerBound, upperBound,
value);
+ }
+ }
+ };
+ }
+
+ /**
+ * Creates the exception reported when a value falls outside an inclusive
range. The value may be of a wider type
+ * than the bounds, for example a number that does not fit the parameter's
value type.
+ */
+ public static BadQueryContextException outOfRangeException(
+ final String parameterName,
+ final Object lowerBound,
+ final Object upperBound,
+ final Object value
+ )
+ {
+ return new BadQueryContextException(
+ StringUtils.format(
+ "Query context parameter [%s] must be within the range [%s, %s],
but was [%s]",
+ parameterName,
+ lowerBound,
+ upperBound,
+ value
+ )
+ );
+ }
+}
diff --git
a/processing/src/main/java/org/apache/druid/query/context/docs/ParameterDocumentation.java
b/processing/src/main/java/org/apache/druid/query/context/docs/ParameterDocumentation.java
new file mode 100644
index 00000000000..83a1f6277f2
--- /dev/null
+++
b/processing/src/main/java/org/apache/druid/query/context/docs/ParameterDocumentation.java
@@ -0,0 +1,203 @@
+/*
+ * 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.context.docs;
+
+import org.apache.druid.java.util.common.IAE;
+
+import javax.annotation.Nullable;
+import java.util.Collections;
+import java.util.EnumSet;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+
+/**
+ * User-facing documentation and applicability metadata for a query context
parameter.
+ */
+public final class ParameterDocumentation
+{
+ /**
+ * Query interface or representation that can provide this parameter.
+ */
+ public enum Query
+ {
+ JSON,
+ SQL
+ }
+
+ /**
+ * Query execution engine that honors this parameter.
+ */
+ public enum Engine
+ {
+ NATIVE,
+ MSQ,
+ DART
+ }
+
+ public enum QueryType
+ {
+ SCAN,
+ TIMESERIES,
+ TOP_N,
+ GROUP_BY
+ }
+
+ public enum StatementType
+ {
+ SELECT,
+ INSERT,
+ REPLACE
+ }
+
+ private final String description;
+ private final Set<Query> queries;
+ private final Set<Engine> engines;
+ private final Set<QueryType> queryTypes;
+ private final Set<StatementType> statementTypes;
+ private final Optional<String> defaultDescription;
+ private final Optional<String> since;
+
+ private ParameterDocumentation(final Builder builder)
+ {
+ this.description = requireNonBlank(builder.description, "description");
+ this.queries = Set.copyOf(builder.queries);
+ this.engines = Set.copyOf(builder.engines);
+ this.queryTypes = Set.copyOf(builder.queryTypes);
+ this.statementTypes = Set.copyOf(builder.statementTypes);
+ this.defaultDescription = Optional.ofNullable(builder.defaultDescription);
+ this.since = Optional.ofNullable(builder.since);
+ }
+
+ public static Builder builder()
+ {
+ return new Builder();
+ }
+
+ public String getDescription()
+ {
+ return description;
+ }
+
+ public Set<Query> getQueries()
+ {
+ return queries;
+ }
+
+ public Set<Engine> getEngines()
+ {
+ return engines;
+ }
+
+ public Set<QueryType> getQueryTypes()
+ {
+ return queryTypes;
+ }
+
+ public Set<StatementType> getStatementTypes()
+ {
+ return statementTypes;
+ }
+
+ public Optional<String> getDefaultDescription()
+ {
+ return defaultDescription;
+ }
+
+ public Optional<String> getSince()
+ {
+ return since;
+ }
+
+ private static String requireNonBlank(@Nullable final String value, final
String name)
+ {
+ final String nonNullValue = Objects.requireNonNull(value, name);
+ if (nonNullValue.isBlank()) {
+ throw new IAE("Query context parameter documentation %s must not be
blank", name);
+ }
+ return nonNullValue;
+ }
+
+ /** Not thread-safe. */
+ public static final class Builder
+ {
+ @Nullable
+ private String description;
+ private final Set<Query> queries = EnumSet.noneOf(Query.class);
+ private final Set<Engine> engines = EnumSet.noneOf(Engine.class);
+ private final Set<QueryType> queryTypes = EnumSet.noneOf(QueryType.class);
+ private final Set<StatementType> statementTypes =
EnumSet.noneOf(StatementType.class);
+ @Nullable
+ private String defaultDescription;
+ @Nullable
+ private String since;
+
+ private Builder()
+ {
+ }
+
+ public Builder description(final String description)
+ {
+ this.description = requireNonBlank(description, "description");
+ return this;
+ }
+
+ public Builder query(final Query... queries)
+ {
+ Collections.addAll(this.queries, queries);
+ return this;
+ }
+
+ public Builder engine(final Engine... engines)
+ {
+ Collections.addAll(this.engines, engines);
+ return this;
+ }
+
+ public Builder queryType(final QueryType... queryTypes)
+ {
+ Collections.addAll(this.queryTypes, queryTypes);
+ return this;
+ }
+
+ public Builder statement(final StatementType... statementTypes)
+ {
+ Collections.addAll(this.statementTypes, statementTypes);
+ return this;
+ }
+
+ public Builder defaultDescription(final String defaultDescription)
+ {
+ this.defaultDescription = requireNonBlank(defaultDescription, "default
description");
+ return this;
+ }
+
+ public Builder since(final String since)
+ {
+ this.since = requireNonBlank(since, "since");
+ return this;
+ }
+
+ public ParameterDocumentation build()
+ {
+ return new ParameterDocumentation(this);
+ }
+ }
+}
diff --git
a/processing/src/main/java/org/apache/druid/query/context/docs/ParameterDocumentationGenerator.java
b/processing/src/main/java/org/apache/druid/query/context/docs/ParameterDocumentationGenerator.java
new file mode 100644
index 00000000000..afd76b99e69
--- /dev/null
+++
b/processing/src/main/java/org/apache/druid/query/context/docs/ParameterDocumentationGenerator.java
@@ -0,0 +1,249 @@
+/*
+ * 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.context.docs;
+
+import org.apache.druid.java.util.common.ISE;
+import org.apache.druid.java.util.common.StringUtils;
+import org.apache.druid.query.context.QueryContextParameter;
+import org.apache.druid.query.context.QueryContextParameters;
+import org.apache.druid.query.context.constraint.ParameterConstraint;
+import org.apache.druid.query.context.constraint.Range;
+import org.apache.druid.query.context.docs.ParameterDocumentation.Query;
+import org.apache.druid.query.context.docs.ParameterDocumentation.QueryType;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.regex.Pattern;
+
+/**
+ * Generates the checked-in query context parameter table rows from the
descriptor catalog.
+ */
+public final class ParameterDocumentationGenerator
+{
+ private static final String GENERAL_REFERENCE =
"docs/querying/query-context-reference.md";
+ private static final String SCAN_REFERENCE = "docs/querying/scan-query.md";
+ private static final String SQL_REFERENCE =
"docs/querying/sql-query-context.md";
+ private static final String MARKER_FORMAT = "<!-- GENERATED QUERY CONTEXT
PARAMETER: %s -->";
+ private static final Pattern TEXT_BLOCK_LINE_CONTINUATION =
Pattern.compile("\\\\[ \\t]*\\R");
+ private static final Pattern LINE_BREAK = Pattern.compile("\\R");
+ private static final Pattern HORIZONTAL_WHITESPACE = Pattern.compile("[
\\t]+");
+
+ private ParameterDocumentationGenerator()
+ {
+ }
+
+ public static void main(final String[] args) throws IOException
+ {
+ if (args.length != 3) {
+ throw new ISE("Expected arguments: <repository root>
<verify|generate|skip> <generated output directory>");
+ }
+
+ final Mode mode = Mode.valueOf(args[1].toUpperCase(Locale.ENGLISH));
+ if (mode == Mode.SKIP) {
+ return;
+ }
+
+ final Path repositoryRoot = Path.of(args[0]);
+ final Path generatedOutputDirectory = Path.of(args[2]);
+ final Map<String, Map<String, String>> rowsByDocument = renderRows();
+
+ for (final Map.Entry<String, Map<String, String>> document :
rowsByDocument.entrySet()) {
+ final Path sourcePath = repositoryRoot.resolve(document.getKey());
+ final String source = Files.readString(sourcePath,
StandardCharsets.UTF_8);
+ final String generated = replaceRows(source, document.getValue(),
sourcePath);
+ final Path generatedPath =
generatedOutputDirectory.resolve(document.getKey());
+ Files.createDirectories(generatedPath.getParent());
+ Files.writeString(generatedPath, generated, StandardCharsets.UTF_8);
+
+ if (mode == Mode.GENERATE) {
+ Files.writeString(sourcePath, generated, StandardCharsets.UTF_8);
+ } else if (!source.equals(generated)) {
+ throw new ISE(
+ "Generated query context documentation is stale in [%s]. "
+ + "Run Maven with [-Dquery.context.docs.mode=generate].",
+ sourcePath
+ );
+ }
+ }
+ }
+
+ private static Map<String, Map<String, String>> renderRows()
+ {
+ final Map<String, Map<String, String>> rowsByDocument = new
LinkedHashMap<>();
+ for (final QueryContextParameter<?> parameter :
QueryContextParameters.ALL.get().values()) {
+ final ParameterDocumentation docs =
parameter.getDocumentation().orElse(null);
+ if (docs == null) {
+ continue;
+ }
+ final String document;
+ if (docs.getQueries().contains(Query.SQL) &&
!docs.getQueries().contains(Query.JSON)) {
+ document = SQL_REFERENCE;
+ } else if (docs.getQueryTypes().contains(QueryType.SCAN)) {
+ document = SCAN_REFERENCE;
+ } else {
+ document = GENERAL_REFERENCE;
+ }
+ rowsByDocument.computeIfAbsent(document, ignored -> new
LinkedHashMap<>())
+ .put(parameter.getName(), renderRow(parameter, docs,
document));
+ }
+ return rowsByDocument;
+ }
+
+ private static String renderRow(
+ final QueryContextParameter<?> parameter,
+ final ParameterDocumentation docs,
+ final String document
+ )
+ {
+ if (SCAN_REFERENCE.equals(document)) {
+ return StringUtils.format(
+ "|%s|%s|%s|%s|",
+ parameter.getName(),
+ escapeTableCell(docs.getDescription()),
+ renderValueDescription(parameter),
+ renderDefault(parameter, docs)
+ );
+ }
+
+ if (SQL_REFERENCE.equals(document)) {
+ return StringUtils.format(
+ "|`%s`|%s|%s|",
+ parameter.getName(),
+ escapeTableCell(docs.getDescription()),
+ renderDefault(parameter, docs)
+ );
+ }
+
+ return StringUtils.format(
+ "|`%s`| %s | %s|",
+ parameter.getName(),
+ renderDefault(parameter, docs),
+ escapeTableCell(docs.getDescription())
+ );
+ }
+
+ private static String renderValueDescription(final QueryContextParameter<?>
parameter)
+ {
+ final StringBuilder description = new StringBuilder();
+ if (Integer.class.equals(parameter.getValueType())) {
+ description.append("An integer");
+ } else if (Long.class.equals(parameter.getValueType())) {
+ description.append("A long integer");
+ } else if (Boolean.class.equals(parameter.getValueType())) {
+ description.append("A Boolean");
+ } else if (String.class.equals(parameter.getValueType())) {
+ description.append("A string");
+ } else {
+ description.append(parameter.getValueType().getSimpleName());
+ }
+
+ for (final ParameterConstraint<?> constraint : parameter.getConstraints())
{
+ if (constraint instanceof Range.Constraint<?>) {
+ final Range.Constraint<?> range = (Range.Constraint<?>) constraint;
+ description.append(" in [")
+ .append(range.getLowerBound())
+ .append(", ")
+ .append(range.getUpperBound())
+ .append(']');
+ } else {
+ throw new ISE(
+ "Documentation generation is not implemented for constraint [%s]",
+ constraint.getClass().getName()
+ );
+ }
+ }
+ return description.toString();
+ }
+
+ private static String renderDefault(
+ final QueryContextParameter<?> parameter,
+ final ParameterDocumentation docs
+ )
+ {
+ return docs.getDefaultDescription()
+ .map(ParameterDocumentationGenerator::normalizeTableCell)
+ .or(() -> parameter.getDefaultValue().map(value -> "`" + value
+ "`"))
+ .orElse("N/A");
+ }
+
+ private static String replaceRows(
+ final String source,
+ final Map<String, String> rows,
+ final Path sourcePath
+ )
+ {
+ final List<String> lines = source.lines().toList();
+ final StringBuilder output = new StringBuilder(source.length());
+ final Map<String, String> remainingRows = new LinkedHashMap<>(rows);
+
+ for (final String line : lines) {
+ boolean replaced = false;
+ for (final Map.Entry<String, String> row :
List.copyOf(remainingRows.entrySet())) {
+ final String marker = StringUtils.format(MARKER_FORMAT, row.getKey());
+ if (line.endsWith(marker)) {
+ output.append(row.getValue()).append('
').append(marker).append('\n');
+ remainingRows.remove(row.getKey());
+ replaced = true;
+ break;
+ }
+ }
+ if (!replaced) {
+ output.append(line).append('\n');
+ }
+ }
+
+ if (!remainingRows.isEmpty()) {
+ throw new ISE("Missing generated query context parameter markers %s in
[%s]", remainingRows.keySet(), sourcePath);
+ }
+ return output.toString();
+ }
+
+ private static String escapeTableCell(final String value)
+ {
+ return StringUtils.replace(normalizeTableCell(value), "|", "\\|");
+ }
+
+ /**
+ * Markdown table cells must be rendered on one physical line. Parameter
descriptions and default descriptions are
+ * commonly written as Java text blocks, so remove text-block line
continuations, flatten remaining line breaks, and
+ * collapse incidental whitespace before writing the generated documentation.
+ */
+ static String normalizeTableCell(final String value)
+ {
+ return HORIZONTAL_WHITESPACE.matcher(
+
LINE_BREAK.matcher(TEXT_BLOCK_LINE_CONTINUATION.matcher(value).replaceAll("")).replaceAll("
")
+ )
+ .replaceAll(" ")
+ .trim();
+ }
+
+ private enum Mode
+ {
+ VERIFY,
+ GENERATE,
+ SKIP
+ }
+}
diff --git
a/processing/src/main/java/org/apache/druid/query/scan/ScanQuery.java
b/processing/src/main/java/org/apache/druid/query/scan/ScanQuery.java
index 57f3a8772fc..29b77c5f408 100644
--- a/processing/src/main/java/org/apache/druid/query/scan/ScanQuery.java
+++ b/processing/src/main/java/org/apache/druid/query/scan/ScanQuery.java
@@ -38,6 +38,7 @@ import org.apache.druid.query.InlineDataSource;
import org.apache.druid.query.Order;
import org.apache.druid.query.OrderBy;
import org.apache.druid.query.Queries;
+import org.apache.druid.query.context.QueryContextParameters;
import org.apache.druid.query.filter.DimFilter;
import org.apache.druid.query.operator.OffsetLimit;
import org.apache.druid.query.spec.QuerySegmentSpec;
@@ -206,13 +207,7 @@ public class ScanQuery extends BaseQuery<ScanResultValue>
private Integer validateAndGetMaxRowsQueuedForOrdering()
{
- final Integer maxRowsQueuedForOrdering =
- context().getInt(ScanQueryConfig.CTX_KEY_MAX_ROWS_QUEUED_FOR_ORDERING);
- Preconditions.checkArgument(
- maxRowsQueuedForOrdering == null || maxRowsQueuedForOrdering > 0,
- "maxRowsQueuedForOrdering must be greater than 0"
- );
- return maxRowsQueuedForOrdering;
+ return context().get(QueryContextParameters.MAX_ROWS_QUEUED_FOR_ORDERING);
}
private Integer validateAndGetMaxSegmentPartitionsOrderedInMemory()
diff --git
a/processing/src/main/java/org/apache/druid/query/scan/ScanQueryConfig.java
b/processing/src/main/java/org/apache/druid/query/scan/ScanQueryConfig.java
index aca2f00446f..d181d278f75 100644
--- a/processing/src/main/java/org/apache/druid/query/scan/ScanQueryConfig.java
+++ b/processing/src/main/java/org/apache/druid/query/scan/ScanQueryConfig.java
@@ -25,7 +25,6 @@ import java.util.Objects;
public class ScanQueryConfig
{
- public static final String CTX_KEY_MAX_ROWS_QUEUED_FOR_ORDERING =
"maxRowsQueuedForOrdering";
public static final String CTX_KEY_MAX_SEGMENT_PARTITIONS_FOR_ORDERING =
"maxSegmentPartitionsOrderedInMemory";
@JsonProperty
diff --git
a/processing/src/test/java/org/apache/druid/query/QueryContextBuilderTest.java
b/processing/src/test/java/org/apache/druid/query/QueryContextBuilderTest.java
new file mode 100644
index 00000000000..92930803d29
--- /dev/null
+++
b/processing/src/test/java/org/apache/druid/query/QueryContextBuilderTest.java
@@ -0,0 +1,84 @@
+/*
+ * 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;
+
+import com.google.common.collect.ImmutableMap;
+import org.apache.druid.query.context.QueryContextParameters;
+import org.junit.jupiter.api.Test;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+class QueryContextBuilderTest
+{
+ @Test
+ void testPutAllAndTypedParameters()
+ {
+ final Map<String, Object> context = QueryContext.builder()
+ .putAll(ImmutableMap.of("legacy", 1))
+ .put(QueryContextParameters.MAX_ROWS_QUEUED_FOR_ORDERING, 10)
+ .put(QueryContextParameters.USE_RESULT_LEVEL_CACHE, false)
+ .toMap();
+
+ assertEquals(
+ ImmutableMap.of(
+ "legacy", 1,
+ QueryContextParameters.MAX_ROWS_QUEUED_FOR_ORDERING.getName(), 10,
+ QueryContextParameters.USE_RESULT_LEVEL_CACHE.getName(), false
+ ),
+ context
+ );
+ }
+
+ @Test
+ void testLaterValueReplacesEarlierValue()
+ {
+ final Map<String, Object> context = QueryContext.builder()
+ .put(QueryContextParameters.MAX_ROWS_QUEUED_FOR_ORDERING, 10)
+ .putAll(ImmutableMap.of("maxRowsQueuedForOrdering", 20))
+ .toMap();
+
+ assertEquals(20,
context.get(QueryContextParameters.MAX_ROWS_QUEUED_FOR_ORDERING.getName()));
+ }
+
+ @Test
+ void testTypedPutNullRemovesParameter()
+ {
+ final Map<String, Object> context = QueryContext.builder()
+ .put(QueryContextParameters.USE_RESULT_LEVEL_CACHE, false)
+ .put(QueryContextParameters.USE_RESULT_LEVEL_CACHE, null)
+ .toMap();
+
+
assertFalse(context.containsKey(QueryContextParameters.USE_RESULT_LEVEL_CACHE.getName()));
+ }
+
+ @Test
+ void testPutAllRejectsNull()
+ {
+ final Map<String, Object> withNullValue = new HashMap<>();
+ withNullValue.put("legacy", null);
+
+ assertThrows(NullPointerException.class, () ->
QueryContext.builder().putAll(withNullValue));
+ }
+}
diff --git
a/processing/src/test/java/org/apache/druid/query/QueryContextTest.java
b/processing/src/test/java/org/apache/druid/query/QueryContextTest.java
index 51a9ef8cadb..2d2b450d375 100644
--- a/processing/src/test/java/org/apache/druid/query/QueryContextTest.java
+++ b/processing/src/test/java/org/apache/druid/query/QueryContextTest.java
@@ -28,10 +28,13 @@ import com.google.common.collect.Ordering;
import nl.jqno.equalsverifier.EqualsVerifier;
import nl.jqno.equalsverifier.Warning;
import org.apache.druid.java.util.common.HumanReadableBytes;
+import org.apache.druid.java.util.common.ISE;
import org.apache.druid.java.util.common.Intervals;
import org.apache.druid.java.util.common.granularity.Granularities;
import org.apache.druid.java.util.common.granularity.Granularity;
import org.apache.druid.query.aggregation.CountAggregatorFactory;
+import org.apache.druid.query.context.QueryContextParameter;
+import org.apache.druid.query.context.QueryContextParameters;
import org.apache.druid.query.filter.DimFilter;
import org.apache.druid.query.spec.QuerySegmentSpec;
import org.apache.druid.segment.DimensionHandlerUtils;
@@ -98,6 +101,33 @@ public class QueryContextTest
}
}
+ @Test
+ public void testOfTypedParameters()
+ {
+ assertEquals(
+ ImmutableMap.of(
+ QueryContextParameters.MAX_ROWS_QUEUED_FOR_ORDERING.getName(), 10,
+ QueryContextParameters.USE_RESULT_LEVEL_CACHE.getName(), false
+ ),
+ QueryContext.ofMap(
+ QueryContextParameters.MAX_ROWS_QUEUED_FOR_ORDERING,
+ 10,
+ QueryContextParameters.USE_RESULT_LEVEL_CACHE,
+ false
+ )
+ );
+
+ assertEquals(
+ false,
+ QueryContext.of(QueryContextParameters.USE_RESULT_LEVEL_CACHE, false)
+ .get(QueryContextParameters.USE_RESULT_LEVEL_CACHE)
+ );
+ assertThrows(
+ BadQueryContextException.class,
+ () ->
QueryContext.ofMap(QueryContextParameters.MAX_ROWS_QUEUED_FOR_ORDERING, 0)
+ );
+ }
+
@Test
public void testIsEmpty()
{
@@ -138,6 +168,55 @@ public class QueryContextTest
assertNull(context.getBoolean("non-exist"));
}
+ @Test
+ public void testGetParameter()
+ {
+ final QueryContext context = QueryContext.of(
+ ImmutableMap.of(
+ QueryContextParameters.MAX_ROWS_QUEUED_FOR_ORDERING.getName(),
"10",
+ QueryContextParameters.USE_RESULT_LEVEL_CACHE.getName(), "false"
+ )
+ );
+
+
assertTrue(context.has(QueryContextParameters.MAX_ROWS_QUEUED_FOR_ORDERING));
+ assertEquals(10,
context.get(QueryContextParameters.MAX_ROWS_QUEUED_FOR_ORDERING));
+ assertFalse(context.get(QueryContextParameters.USE_RESULT_LEVEL_CACHE));
+
assertFalse(context.getOrDefault(QueryContextParameters.USE_RESULT_LEVEL_CACHE));
+
+ final QueryContext emptyContext = QueryContext.empty();
+
assertFalse(emptyContext.has(QueryContextParameters.MAX_ROWS_QUEUED_FOR_ORDERING));
+
assertNull(emptyContext.get(QueryContextParameters.MAX_ROWS_QUEUED_FOR_ORDERING));
+ assertEquals(20,
emptyContext.getOrDefault(QueryContextParameters.MAX_ROWS_QUEUED_FOR_ORDERING,
20));
+ assertThrows(ISE.class, () ->
emptyContext.getOrDefault(QueryContextParameters.MAX_ROWS_QUEUED_FOR_ORDERING));
+
assertTrue(emptyContext.get(QueryContextParameters.USE_RESULT_LEVEL_CACHE));
+
assertFalse(emptyContext.getOrDefault(QueryContextParameters.USE_RESULT_LEVEL_CACHE,
false));
+
assertTrue(emptyContext.getOrDefault(QueryContextParameters.USE_RESULT_LEVEL_CACHE));
+ }
+
+ @Test
+ public void testGetParameterTreatsExplicitNullAsUnset()
+ {
+ final QueryContextParameter<Integer> nullableWithDefault =
QueryContextParameter
+ .builder("nullable", Integer.class, value -> (Integer) value)
+ .defaultValue(10)
+ .build();
+ final QueryContextParameter<Integer> nonNullable = QueryContextParameter
+ .builder("nonNullable", Integer.class, value -> (Integer) value)
+ .nullable(false)
+ .build();
+ final Map<String, Object> values = new HashMap<>();
+ values.put(nullableWithDefault.getName(), null);
+ values.put(nonNullable.getName(), null);
+ final QueryContext context = QueryContext.of(values);
+
+ assertFalse(context.has(nullableWithDefault));
+ assertEquals(10, context.get(nullableWithDefault));
+ assertEquals(10, context.getOrDefault(nullableWithDefault));
+ assertEquals(20, context.getOrDefault(nullableWithDefault, 20));
+ assertThrows(BadQueryContextException.class, () ->
context.get(nonNullable));
+ assertThrows(BadQueryContextException.class, () ->
context.getOrDefault(nonNullable, 20));
+ }
+
@Test
public void testGetInt()
{
@@ -416,6 +495,33 @@ public class QueryContextTest
assertEquals(context, legacy.getContext());
}
+ @Test
+ public void testWithOverriddenContextParameter()
+ {
+ final Query<?> query = new LegacyContextQuery(ImmutableMap.of());
+
+ final Query<?> overriddenQuery = query.withOverriddenContext(
+ QueryContextParameters.MAX_ROWS_QUEUED_FOR_ORDERING,
+ 10
+ );
+
+ assertEquals(10,
overriddenQuery.context().get(QueryContextParameters.MAX_ROWS_QUEUED_FOR_ORDERING));
+
+ assertThrows(
+ BadQueryContextException.class,
+ () ->
query.withOverriddenContext(QueryContextParameters.MAX_ROWS_QUEUED_FOR_ORDERING,
0)
+ );
+
+ final Query<?> nullOverriddenQuery = query.withOverriddenContext(
+ QueryContextParameters.USE_RESULT_LEVEL_CACHE,
+ null
+ );
+
assertTrue(nullOverriddenQuery.getContext().containsKey(QueryContextParameters.USE_RESULT_LEVEL_CACHE.getName()));
+
assertNull(nullOverriddenQuery.getContext().get(QueryContextParameters.USE_RESULT_LEVEL_CACHE.getName()));
+
assertFalse(nullOverriddenQuery.context().has(QueryContextParameters.USE_RESULT_LEVEL_CACHE));
+ assertTrue(nullOverriddenQuery.context().isUseResultLevelCache());
+ }
+
@Test
public void testNonLegacyIsNotLegacyContext()
{
@@ -429,18 +535,6 @@ public class QueryContextTest
assertNotNull(timeseries.getContext());
}
- @Test
- @SuppressWarnings("deprecation")
- public void testIsRealtimeSegmentsOnly()
- {
- assertFalse(QueryContext.empty().isRealtimeSegmentsOnly());
- assertTrue(
- QueryContext
- .of(ImmutableMap.of(QueryContexts.REALTIME_SEGMENTS_ONLY, true))
- .isRealtimeSegmentsOnly()
- );
- }
-
@Test
public void testGetRealtimeSegmentsMode()
{
diff --git
a/processing/src/test/java/org/apache/druid/query/context/QueryContextParameterTest.java
b/processing/src/test/java/org/apache/druid/query/context/QueryContextParameterTest.java
new file mode 100644
index 00000000000..5567bb840a2
--- /dev/null
+++
b/processing/src/test/java/org/apache/druid/query/context/QueryContextParameterTest.java
@@ -0,0 +1,320 @@
+/*
+ * 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.context;
+
+import org.apache.druid.java.util.common.IAE;
+import org.apache.druid.java.util.common.ISE;
+import org.apache.druid.query.BadQueryContextException;
+import org.apache.druid.query.QueryContexts;
+import org.apache.druid.query.context.constraint.Range;
+import org.apache.druid.query.context.docs.ParameterDocumentation;
+import org.apache.druid.query.context.docs.ParameterDocumentation.Engine;
+import org.apache.druid.query.context.docs.ParameterDocumentation.Query;
+import org.apache.druid.query.context.docs.ParameterDocumentation.QueryType;
+import
org.apache.druid.query.context.docs.ParameterDocumentation.StatementType;
+import org.junit.jupiter.api.Test;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import static org.apache.druid.query.context.constraint.Range.closedRange;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class QueryContextParameterTest
+{
+ @Test
+ void testDescriptorMetadataAndParsing()
+ {
+ final QueryContextParameter<Integer> parameter = QueryContextParameter
+ .builder("maxThings", Integer.class, value ->
QueryContexts.getAsInt("maxThings", value))
+ .constraint(closedRange(0, Integer.MAX_VALUE))
+ .defaultValue(10)
+ .description("Maximum number of things.")
+ .query(Query.JSON)
+ .engine(Engine.NATIVE)
+ .queryType(QueryType.SCAN)
+ .statement(StatementType.SELECT)
+ .defaultDescription("runtime configuration")
+ .since("39.0.0")
+ .build();
+
+ assertEquals("maxThings", parameter.getName());
+ assertEquals(Integer.class, parameter.getValueType());
+ assertEquals(12, parameter.parse("12"));
+ assertEquals(10, parameter.getDefaultValue().orElseThrow());
+ assertTrue(parameter.isNullable());
+ assertFalse(parameter.isDeprecated());
+ assertTrue(parameter.getDeprecationMessage().isEmpty());
+ final ParameterDocumentation docs =
parameter.getDocumentation().orElseThrow();
+ assertEquals("39.0.0", docs.getSince().orElseThrow());
+ assertEquals("Maximum number of things.", docs.getDescription());
+ assertEquals(Set.of(Query.JSON), docs.getQueries());
+ assertEquals(Set.of(Engine.NATIVE), docs.getEngines());
+ assertEquals(Set.of(QueryType.SCAN), docs.getQueryTypes());
+ assertEquals(Set.of(StatementType.SELECT), docs.getStatementTypes());
+ assertEquals("runtime configuration",
docs.getDefaultDescription().orElseThrow());
+ final Range.Constraint<?> constraint = (Range.Constraint<?>)
parameter.getConstraints().get(0);
+ assertEquals(0, constraint.getLowerBound());
+ assertEquals(Integer.MAX_VALUE, constraint.getUpperBound());
+ assertEquals("maxThings", parameter.toString());
+ }
+
+ @Test
+ void testValidatorRejectsParsedValue()
+ {
+ final QueryContextParameter<Integer> parameter = QueryContextParameter
+ .builder("maxThings", Integer.class, value -> (Integer) value)
+ .constraint(closedRange(0, 10))
+ .build();
+
+ assertThrows(BadQueryContextException.class, () -> parameter.parse(-1));
+ assertThrows(BadQueryContextException.class, () -> parameter.validate(-1));
+ assertThrows(BadQueryContextException.class, () -> parameter.validate(11));
+ parameter.validate(0);
+ parameter.validate(10);
+ }
+
+ @Test
+ void testValidateDoesNotInvokeParser()
+ {
+ final AtomicBoolean parserCalled = new AtomicBoolean();
+ final QueryContextParameter<Integer> parameter = QueryContextParameter
+ .builder(
+ "maxThings",
+ Integer.class,
+ value -> {
+ parserCalled.set(true);
+ return QueryContexts.getAsInt("maxThings", value);
+ }
+ )
+ .build();
+
+ assertEquals(1, parameter.validate(1));
+ assertFalse(parserCalled.get());
+
+ assertEquals(1, parameter.parse(1));
+ assertFalse(parserCalled.get());
+
+ assertEquals(1, parameter.parse("1"));
+ assertTrue(parserCalled.get());
+ }
+
+ @Test
+ void testParseOrDefault()
+ {
+ final QueryContextParameter<Integer> parameter = QueryContextParameter
+ .builder("maxThings", Integer.class, value ->
QueryContexts.getAsInt("maxThings", value))
+ .defaultValue(10)
+ .build();
+
+ assertEquals(12, parameter.parseOrDefault("12"));
+ assertEquals(10, parameter.parseOrDefault(null));
+ }
+
+ @Test
+ void testParseOrDefaultRequiresDeclaredDefault()
+ {
+ final QueryContextParameter<String> parameter = QueryContextParameter
+ .builder("tag", String.class, value -> (String) value)
+ .build();
+
+ assertThrows(ISE.class, () -> parameter.parseOrDefault(null));
+ }
+
+ @Test
+ void testRangeRejectsInvalidBounds()
+ {
+ assertThrows(IAE.class, () -> closedRange(1, 0));
+ }
+
+ @Test
+ void testNullableValueSkipsConstraints()
+ {
+ final AtomicBoolean constraintCalled = new AtomicBoolean();
+ final QueryContextParameter<Integer> parameter = QueryContextParameter
+ .builder("required", Integer.class, value -> (Integer) value)
+ .constraint((parameterName, value) -> constraintCalled.set(true))
+ .build();
+
+ assertNull(parameter.validate(null));
+ assertFalse(constraintCalled.get());
+ parameter.validate(1);
+ assertTrue(constraintCalled.get());
+ }
+
+ @Test
+ void testNonNullableParameterRejectsNull()
+ {
+ final QueryContextParameter<String> parameter = QueryContextParameter
+ .builder("required", String.class, String::valueOf)
+ .nullable(false)
+ .build();
+ final Map<String, Object> context = new HashMap<>();
+
+ assertFalse(parameter.isNullable());
+ assertThrows(BadQueryContextException.class, () -> parameter.parse(null));
+ assertThrows(BadQueryContextException.class, () ->
parameter.validate(null));
+ assertThrows(BadQueryContextException.class, () -> parameter.set(context,
null));
+ assertTrue(context.isEmpty());
+
+ final QueryContextParameter<String> nullProducingParser =
QueryContextParameter
+ .builder("required", String.class, ignored -> null)
+ .nullable(false)
+ .build();
+ assertThrows(BadQueryContextException.class, () ->
nullProducingParser.parse(42));
+ }
+
+ @Test
+ void testIntegerParameterRejectsLossyNumbers()
+ {
+ final QueryContextParameter<Integer> parameter =
QueryContextParameters.integerParameter("ints").build();
+
+ assertEquals(12, parameter.parse(12L));
+ assertEquals(12, parameter.parse(12.0d));
+ assertEquals(12, parameter.parse(new java.math.BigDecimal("12.00")));
+ assertEquals(12, parameter.parse("12"));
+ assertEquals(Integer.MAX_VALUE, parameter.parse((long) Integer.MAX_VALUE));
+ assertThrows(BadQueryContextException.class, () ->
parameter.parse(4_294_967_295L));
+ assertThrows(BadQueryContextException.class, () ->
parameter.parse(Double.NaN));
+ assertEquals(
+ "Query context parameter [ints] must be within the range [-2147483648,
2147483647], but was [5000000000]",
+ assertThrows(BadQueryContextException.class, () ->
parameter.parse(5_000_000_000L)).getMessage()
+ );
+ assertEquals(
+ "Query context parameter [ints] should be in integer format, but got
[12.7]",
+ assertThrows(BadQueryContextException.class, () ->
parameter.parse(12.7d)).getMessage()
+ );
+ assertEquals(
+ "Query context parameter [ints] should be in integer format, but got
[not-an-int]",
+ assertThrows(BadQueryContextException.class, () ->
parameter.parse("not-an-int")).getMessage()
+ );
+
+ // An overflowing value reports the parameter's own range constraint when
one is declared.
+ final QueryContextParameter<Integer> constrained =
QueryContextParameters.integerParameter("ints")
+
.constraint(closedRange(1, 100))
+
.build();
+ assertEquals(
+ "Query context parameter [ints] must be within the range [1, 100], but
was [5000000000]",
+ assertThrows(BadQueryContextException.class, () ->
constrained.parse("5000000000")).getMessage()
+ );
+ }
+
+ @Test
+ void testLongParameterRejectsLossyNumbers()
+ {
+ final QueryContextParameter<Long> parameter =
QueryContextParameters.longParameter("longs").build();
+
+ assertEquals(12L, parameter.parse(12));
+ assertEquals(12L, parameter.parse(12.0d));
+ assertEquals(Long.MAX_VALUE, parameter.parse(new
java.math.BigInteger(String.valueOf(Long.MAX_VALUE))));
+ assertThrows(
+ BadQueryContextException.class,
+ () -> parameter.parse(new
java.math.BigInteger(String.valueOf(Long.MAX_VALUE)).add(java.math.BigInteger.ONE))
+ );
+ assertThrows(BadQueryContextException.class, () -> parameter.parse(12.5d));
+ assertThrows(BadQueryContextException.class, () ->
parameter.parse("9223372036854775808"));
+ }
+
+ @Test
+ void testStringParameterRejectsNonStrings()
+ {
+ final QueryContextParameter<String> parameter =
QueryContextParameters.stringParameter("strings").build();
+
+ assertEquals("value", parameter.parse("value"));
+ assertEquals(
+ "Query context parameter [strings] should be a string, but got [1]",
+ assertThrows(BadQueryContextException.class, () ->
parameter.parse(1)).getMessage()
+ );
+ }
+
+ @Test
+ void testBooleanParameterRejectsNonBooleans()
+ {
+ assertEquals(
+ "Query context parameter [useResultLevelCache] should be a boolean,
but got [1]",
+ assertThrows(BadQueryContextException.class, () ->
QueryContextParameters.USE_RESULT_LEVEL_CACHE.parse(1))
+ .getMessage()
+ );
+ }
+
+ @Test
+ void testSet()
+ {
+ final QueryContextParameter<String> parameter = QueryContextParameter
+ .builder("tag", String.class, String::valueOf)
+ .build();
+ final Map<String, Object> context = new HashMap<>();
+
+ parameter.set(context, "value");
+ assertEquals(Map.of("tag", "value"), context);
+
+ parameter.set(context, null);
+ assertTrue(context.isEmpty());
+ }
+
+ @Test
+ void testValidatorRejectsDefaultValueAtBuildTime()
+ {
+ final QueryContextParameter.Builder<Integer> builder =
QueryContextParameter
+ .builder("maxThings", Integer.class, value -> (Integer) value)
+ .constraint(closedRange(0, Integer.MAX_VALUE))
+ .defaultValue(-1);
+
+ assertThrows(IAE.class, builder::build);
+ }
+
+ @Test
+ void testDefaultsApplyToOptionalMetadata()
+ {
+ final QueryContextParameter<String> parameter = QueryContextParameter
+ .builder("tag", String.class, value -> String.valueOf(value))
+ .deprecated("Use `newTag` instead.")
+ .build();
+
+ assertTrue(parameter.isDeprecated());
+ assertEquals("Use `newTag` instead.",
parameter.getDeprecationMessage().orElseThrow());
+ assertTrue(parameter.getDefaultValue().isEmpty());
+ assertTrue(parameter.getDocumentation().isEmpty());
+ }
+
+ @Test
+ void testRejectsInvalidName()
+ {
+ assertThrows(
+ IAE.class,
+ () -> QueryContextParameter.builder(" parameter", String.class, value
-> String.valueOf(value))
+ );
+ }
+
+ @Test
+ void testRejectsBlankDeprecationMessage()
+ {
+ final QueryContextParameter.Builder<String> builder = QueryContextParameter
+ .builder("parameter", String.class, String::valueOf);
+
+ assertThrows(IAE.class, () -> builder.deprecated(" "));
+ }
+}
diff --git
a/processing/src/test/java/org/apache/druid/query/context/QueryContextParameterValidatorTest.java
b/processing/src/test/java/org/apache/druid/query/context/QueryContextParameterValidatorTest.java
new file mode 100644
index 00000000000..0057d5e98e0
--- /dev/null
+++
b/processing/src/test/java/org/apache/druid/query/context/QueryContextParameterValidatorTest.java
@@ -0,0 +1,53 @@
+/*
+ * 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.context;
+
+import org.apache.druid.query.BadQueryContextException;
+import org.junit.jupiter.api.Test;
+
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+class QueryContextParameterValidatorTest
+{
+ @Test
+ void testValidateParameter()
+ {
+ assertThrows(BadQueryContextException.class, () ->
QueryContextParameterValidator.validate("maxRowsQueuedForOrdering", -1));
+ assertThrows(BadQueryContextException.class, () ->
QueryContextParameterValidator.validate("maxRowsQueuedForOrdering",
"not-an-int"));
+ assertThrows(BadQueryContextException.class, () ->
QueryContextParameterValidator.validate("useResultLevelCache", 1));
+ QueryContextParameterValidator.validate("maxRowsQueuedForOrdering",
Integer.MAX_VALUE);
+ QueryContextParameterValidator.validate("maxRowsQueuedForOrdering", null);
+ QueryContextParameterValidator.validate("unmigratedParameter", -1);
+ }
+
+ @Test
+ void testValidateParameters()
+ {
+ assertThrows(
+ BadQueryContextException.class,
+ () -> QueryContextParameterValidator.validate(
+ Map.of("maxRowsQueuedForOrdering", 0, "unmigratedParameter", -1)
+ )
+ );
+ QueryContextParameterValidator.validate(Map.of("maxRowsQueuedForOrdering",
1, "unmigratedParameter", -1));
+ }
+}
diff --git
a/processing/src/test/java/org/apache/druid/query/context/docs/ParameterDocumentationGeneratorTest.java
b/processing/src/test/java/org/apache/druid/query/context/docs/ParameterDocumentationGeneratorTest.java
new file mode 100644
index 00000000000..5395a4f4b54
--- /dev/null
+++
b/processing/src/test/java/org/apache/druid/query/context/docs/ParameterDocumentationGeneratorTest.java
@@ -0,0 +1,143 @@
+/*
+ * 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.context.docs;
+
+import org.apache.druid.java.util.common.ISE;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class ParameterDocumentationGeneratorTest
+{
+ private static final String GENERAL_DOCUMENT =
"docs/querying/query-context-reference.md";
+ private static final String SCAN_DOCUMENT = "docs/querying/scan-query.md";
+ private static final String GENERAL_MARKER =
+ "<!-- GENERATED QUERY CONTEXT PARAMETER: useResultLevelCache -->";
+ private static final String SCAN_MARKER =
+ "<!-- GENERATED QUERY CONTEXT PARAMETER: maxRowsQueuedForOrdering -->";
+
+ @TempDir
+ Path temporaryFolder;
+
+ @Test
+ void testGenerateAndVerify() throws IOException
+ {
+ writeDocuments("general header\n|stale| " + GENERAL_MARKER, "scan
header\n|stale| " + SCAN_MARKER);
+
+ final Path generatedOutput = temporaryFolder.resolve("generated");
+ ParameterDocumentationGenerator.main(arguments("generate",
generatedOutput));
+
+ final String general =
Files.readString(temporaryFolder.resolve(GENERAL_DOCUMENT),
StandardCharsets.UTF_8);
+ assertTrue(general.startsWith("general header\n"));
+ assertTrue(general.contains("|`useResultLevelCache`| `true` |"));
+ assertTrue(general.contains(GENERAL_MARKER));
+
+ final String scan =
Files.readString(temporaryFolder.resolve(SCAN_DOCUMENT),
StandardCharsets.UTF_8);
+ assertTrue(scan.startsWith("scan header\n"));
+ assertTrue(scan.contains("|maxRowsQueuedForOrdering|"));
+ assertTrue(scan.contains("An integer in [1, 2147483647]"));
+ assertTrue(scan.contains(SCAN_MARKER));
+
+ assertEquals(general,
Files.readString(generatedOutput.resolve(GENERAL_DOCUMENT),
StandardCharsets.UTF_8));
+ assertEquals(scan,
Files.readString(generatedOutput.resolve(SCAN_DOCUMENT),
StandardCharsets.UTF_8));
+
+ ParameterDocumentationGenerator.main(arguments("verify",
temporaryFolder.resolve("verified")));
+ }
+
+ @Test
+ void testVerifyRejectsStaleDocumentation() throws IOException
+ {
+ writeDocuments("|stale| " + GENERAL_MARKER, "|stale| " + SCAN_MARKER);
+
+ final ISE exception = assertThrows(
+ ISE.class,
+ () -> ParameterDocumentationGenerator.main(arguments("verify",
temporaryFolder.resolve("generated")))
+ );
+ assertTrue(exception.getMessage().contains("is stale"));
+ }
+
+ @Test
+ void testGenerateRejectsMissingMarker() throws IOException
+ {
+ writeDocuments("no generated row", "|stale| " + SCAN_MARKER);
+
+ final ISE exception = assertThrows(
+ ISE.class,
+ () -> ParameterDocumentationGenerator.main(arguments("generate",
temporaryFolder.resolve("generated")))
+ );
+ assertTrue(exception.getMessage().contains("Missing generated query
context parameter markers"));
+ }
+
+ @Test
+ void testSkipDoesNotReadDocuments() throws IOException
+ {
+ final Path generatedOutput = temporaryFolder.resolve("generated");
+ ParameterDocumentationGenerator.main(arguments("skip", generatedOutput));
+ assertFalse(Files.exists(generatedOutput));
+ }
+
+ @Test
+ void testRejectsUnexpectedArguments()
+ {
+ final ISE exception = assertThrows(ISE.class, () ->
ParameterDocumentationGenerator.main(new String[0]));
+ assertTrue(exception.getMessage().contains("Expected arguments"));
+ }
+
+ @Test
+ void testNormalizesTableCell()
+ {
+ final String description = String.join(
+ "",
+ " Description with ",
+ "\\",
+ "\n",
+ " wrapped lines ",
+ "\n and a continuation marker. "
+ );
+
+ assertEquals(
+ "Description with wrapped lines and a continuation marker.",
+ ParameterDocumentationGenerator.normalizeTableCell(description)
+ );
+ }
+
+ private String[] arguments(final String mode, final Path generatedOutput)
+ {
+ return new String[]{temporaryFolder.toString(), mode,
generatedOutput.toString()};
+ }
+
+ private void writeDocuments(final String general, final String scan) throws
IOException
+ {
+ final Path generalPath = temporaryFolder.resolve(GENERAL_DOCUMENT);
+ final Path scanPath = temporaryFolder.resolve(SCAN_DOCUMENT);
+ Files.createDirectories(generalPath.getParent());
+ Files.writeString(generalPath, general, StandardCharsets.UTF_8);
+ Files.writeString(scanPath, scan, StandardCharsets.UTF_8);
+ }
+}
diff --git
a/processing/src/test/java/org/apache/druid/query/scan/ScanQueryResultOrderingTest.java
b/processing/src/test/java/org/apache/druid/query/scan/ScanQueryResultOrderingTest.java
index 50574375929..0d8893fee09 100644
---
a/processing/src/test/java/org/apache/druid/query/scan/ScanQueryResultOrderingTest.java
+++
b/processing/src/test/java/org/apache/druid/query/scan/ScanQueryResultOrderingTest.java
@@ -20,7 +20,6 @@
package org.apache.druid.query.scan;
import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSortedSet;
import com.google.common.collect.Sets;
import org.apache.druid.java.util.common.DateTimes;
@@ -35,6 +34,7 @@ import org.apache.druid.query.Druids;
import org.apache.druid.query.Order;
import org.apache.druid.query.QueryPlus;
import org.apache.druid.query.QueryRunner;
+import org.apache.druid.query.context.QueryContextParameters;
import org.apache.druid.query.context.ResponseContext;
import org.apache.druid.query.spec.MultipleIntervalSegmentSpec;
import org.apache.druid.query.spec.MultipleSpecificSegmentSpec;
@@ -390,10 +390,8 @@ public class ScanQueryResultOrderingTest extends
InitializedNullHandlingTest
.batchSize(testCase.batchSize())
.build()
.withOverriddenContext(
- ImmutableMap.of(
-
ScanQueryConfig.CTX_KEY_MAX_ROWS_QUEUED_FOR_ORDERING,
-
testCase.maxRowsQueuedForOrdering()
- )
+
QueryContextParameters.MAX_ROWS_QUEUED_FOR_ORDERING,
+
testCase.maxRowsQueuedForOrdering()
),
brokerRunner
);
diff --git
a/processing/src/test/java/org/apache/druid/query/scan/ScanQueryTest.java
b/processing/src/test/java/org/apache/druid/query/scan/ScanQueryTest.java
index a27c3bbf93a..1c87c60f2d8 100644
--- a/processing/src/test/java/org/apache/druid/query/scan/ScanQueryTest.java
+++ b/processing/src/test/java/org/apache/druid/query/scan/ScanQueryTest.java
@@ -22,6 +22,7 @@ package org.apache.druid.query.scan;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import nl.jqno.equalsverifier.EqualsVerifier;
import nl.jqno.equalsverifier.Warning;
@@ -30,11 +31,13 @@ import org.apache.druid.java.util.common.ISE;
import org.apache.druid.java.util.common.guava.Sequence;
import org.apache.druid.java.util.common.guava.Sequences;
import org.apache.druid.math.expr.ExprMacroTable;
+import org.apache.druid.query.BadQueryContextException;
import org.apache.druid.query.Druids;
import org.apache.druid.query.Order;
import org.apache.druid.query.OrderBy;
import org.apache.druid.query.Query;
import org.apache.druid.query.QueryRunnerTestHelper;
+import org.apache.druid.query.context.QueryContextParameters;
import org.apache.druid.query.spec.MultipleIntervalSegmentSpec;
import org.apache.druid.query.spec.QuerySegmentSpec;
import org.apache.druid.segment.CursorBuildSpec;
@@ -257,6 +260,29 @@ public class ScanQueryTest extends
InitializedNullHandlingTest
}
}
+ @Test
+ public void testMaxRowsQueuedForOrderingMustBeGreaterThanZero()
+ {
+ // Use a raw context so that ScanQuery itself validates the value.
+ Assertions.assertThrows(
+ BadQueryContextException.class,
+ () -> Druids.newScanQueryBuilder()
+ .dataSource("source")
+ .intervals(intervalSpec)
+
.context(ImmutableMap.of(QueryContextParameters.MAX_ROWS_QUEUED_FOR_ORDERING.getName(),
0))
+ .build()
+ );
+ Assertions.assertEquals(
+ 1,
+ Druids.newScanQueryBuilder()
+ .dataSource("source")
+ .intervals(intervalSpec)
+
.context(ImmutableMap.of(QueryContextParameters.MAX_ROWS_QUEUED_FOR_ORDERING.getName(),
1))
+ .build()
+ .getMaxRowsQueuedForOrdering()
+ );
+ }
+
// Validates that getResultOrdering will work for the broker n-way merge
@Test
public void testMergeSequenceForResults()
diff --git a/sql/src/main/java/org/apache/druid/sql/SqlQueryPlus.java
b/sql/src/main/java/org/apache/druid/sql/SqlQueryPlus.java
index c3ca2b5af69..ebbd7588a0a 100644
--- a/sql/src/main/java/org/apache/druid/sql/SqlQueryPlus.java
+++ b/sql/src/main/java/org/apache/druid/sql/SqlQueryPlus.java
@@ -24,7 +24,10 @@ import org.apache.calcite.avatica.SqlType;
import org.apache.calcite.avatica.remote.TypedValue;
import org.apache.calcite.sql.SqlNode;
import org.apache.druid.error.DruidException;
+import org.apache.druid.error.InvalidSqlInput;
+import org.apache.druid.query.BadQueryContextException;
import org.apache.druid.query.QueryContexts;
+import org.apache.druid.query.context.QueryContextParameterValidator;
import org.apache.druid.query.http.ClientSqlParameter;
import org.apache.druid.server.security.AuthenticationResult;
import org.apache.druid.sql.calcite.parser.DruidSqlParser;
@@ -298,6 +301,12 @@ public class SqlQueryPlus
public SqlQueryPlus build()
{
final StatementAndSetContext statementAndSetContext =
DruidSqlParser.parse(sql, true);
+ try {
+
QueryContextParameterValidator.validate(statementAndSetContext.getSetContext());
+ }
+ catch (BadQueryContextException e) {
+ throw InvalidSqlInput.exception(e, "Invalid query context parameter
value: %s", e.getMessage());
+ }
final Map<String, Object> userProvidedContext =
statementAndSetContext.getSetContext().isEmpty()
? queryContext
: QueryContexts.override(
diff --git a/sql/src/main/java/org/apache/druid/sql/http/SqlResource.java
b/sql/src/main/java/org/apache/druid/sql/http/SqlResource.java
index 923380008d4..abae9e1a9c5 100644
--- a/sql/src/main/java/org/apache/druid/sql/http/SqlResource.java
+++ b/sql/src/main/java/org/apache/druid/sql/http/SqlResource.java
@@ -241,7 +241,7 @@ public class SqlResource
final QueryContext queryContext;
try {
- SqlQueryPlus sqlQueryPlus = makeSqlQueryPlus(sqlQuery, req,
queryConfigProvider.getContext());
+ final SqlQueryPlus sqlQueryPlus = makeSqlQueryPlus(sqlQuery, req,
queryConfigProvider.getContext());
// Redefine queryContext to include SET parameters and default context.
queryContext = new QueryContext(sqlQueryPlus.context());
diff --git a/sql/src/test/java/org/apache/druid/sql/SqlQueryPlusTest.java
b/sql/src/test/java/org/apache/druid/sql/SqlQueryPlusTest.java
index 7e74517bb6e..81770f27d77 100644
--- a/sql/src/test/java/org/apache/druid/sql/SqlQueryPlusTest.java
+++ b/sql/src/test/java/org/apache/druid/sql/SqlQueryPlusTest.java
@@ -30,6 +30,54 @@ import java.util.Map;
public class SqlQueryPlusTest
{
+ @Test
+ public void testSetAcceptsUnmigratedParameters()
+ {
+ final SqlQueryPlus query = SqlQueryPlus.builder("SET lane = 'fast'; SELECT
1")
+
.auth(CalciteTests.REGULAR_USER_AUTH_RESULT)
+ .build();
+
+ Assertions.assertEquals("fast", query.context().get("lane"));
+ }
+
+ @Test
+ public void testSetParameterConstraintIsValidated()
+ {
+ final DruidException e = Assertions.assertThrows(
+ DruidException.class,
+ () -> SqlQueryPlus.builder("SET maxRowsQueuedForOrdering = 0; SELECT
1")
+ .auth(CalciteTests.REGULAR_USER_AUTH_RESULT)
+ .build()
+ );
+
+ BaseCalciteQueryTest.assertDruidException(
+ e,
+ DruidExceptionMatcher
+ .invalidSqlInput()
+ .expectMessageContains(
+ "Query context parameter [maxRowsQueuedForOrdering] must be
within the range [1, 2147483647], but was [0]"
+ )
+ );
+ }
+
+ @Test
+ public void testSetParameterParserFailureIsInvalidSqlInput()
+ {
+ final DruidException e = Assertions.assertThrows(
+ DruidException.class,
+ () -> SqlQueryPlus.builder("SET maxRowsQueuedForOrdering =
'not-an-int'; SELECT 1")
+ .auth(CalciteTests.REGULAR_USER_AUTH_RESULT)
+ .build()
+ );
+
+ BaseCalciteQueryTest.assertDruidException(
+ e,
+ DruidExceptionMatcher
+ .invalidSqlInput()
+ .expectMessageContains("Query context parameter
[maxRowsQueuedForOrdering] should be in integer format, but got [not-an-int]")
+ );
+ }
+
@Test
public void testSyntaxError()
{
diff --git a/sql/src/test/java/org/apache/druid/sql/http/SqlResourceTest.java
b/sql/src/test/java/org/apache/druid/sql/http/SqlResourceTest.java
index 0909be0671c..c55f55ce580 100644
--- a/sql/src/test/java/org/apache/druid/sql/http/SqlResourceTest.java
+++ b/sql/src/test/java/org/apache/druid/sql/http/SqlResourceTest.java
@@ -1534,6 +1534,22 @@ public class SqlResourceTest extends CalciteTestBase
Assertions.assertTrue(lifecycleManager.getAll("id").isEmpty());
}
+ @Test
+ public void testInvalidSetParameterReturnsBadRequest() throws Exception
+ {
+ final ErrorResponse errorResponse = postSyncForException(
+ "SET maxRowsQueuedForOrdering = 0; SELECT 1",
+ Status.BAD_REQUEST.getStatusCode()
+ );
+
+ validateInvalidSqlError(
+ errorResponse,
+ "Query context parameter [maxRowsQueuedForOrdering] must be within the
range [1, 2147483647], but was [0]"
+ );
+ Assertions.assertEquals(0, testRequestLogger.getSqlQueryLogs().size()); //
Invalid queries are not logged
+ Assertions.assertTrue(lifecycleManager.getAll("id").isEmpty());
+ }
+
@Test
public void testCannotValidate() throws Exception
{
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]