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 edcbfe742f1 fix: Validate the first operand of quantile aggregators
(#20782)
edcbfe742f1 is described below
commit edcbfe742f120b0b972da366899ed8479b021a79
Author: havefunatcode <[email protected]>
AuthorDate: Wed Nov 4 11:02:05 2026 +0900
fix: Validate the first operand of quantile aggregators (#20782)
* fix: Validate the first operand of quantile aggregators
APPROX_QUANTILE('m1', 0.5) with a single-quoted column name was planned
with the string literal as input and returned NaN or failed at runtime.
The quantile aggregators declared their first operand as ANY, and
DefaultOperandTypeChecker doesn't check ANY operands.
DefaultOperandTypeChecker gets two opt-in builder options:
operandComplexTypes lists the complex types an operand accepts, and
coercibleOperands lets a numeric operand take STRING input through an
implicit CAST. A string literal that can't be parsed as a number is
rejected with InvalidSqlInput before the cast is added. TIMESTAMP input
keeps working.
APPROX_QUANTILE, APPROX_QUANTILE_FIXED_BUCKETS, APPROX_QUANTILE_DS,
DS_QUANTILES_SKETCH, TDIGEST_QUANTILE and TDIGEST_GENERATE_SKETCH now
declare their first operand as NUMERIC and allow their own sketch type.
BOOLEAN, ARRAY, other complex types and NULL literals are rejected at
planning time.
toDruidExpressionForNumericAggregator also rejects non-numeric string
literals, for aggregators that still declare ANY.
Fixes #18010
* Use makeNullLiteral instead of deprecated constantNull in AggregationsTest
* Declare operand types with OperandType
Replace the operandComplexTypes() and coercibleOperands() builder
options with an OperandType value class, so each operand is declared
in one place, e.g.
OperandType.numeric().orComplex(type).coerceStrings()
DefaultOperandTypeChecker.Builder and
OperatorConversions.OperatorBuilder get an operandTypes(OperandType...)
overload, and the SqlTypeFamily overloads delegate to it.
coerceStrings() fails for non-NUMERIC operands.
* Reject orComplex() for ANY operands and add quantile tests
Add tests for numeric string literals in APPROX_QUANTILE_DS and for
string columns, numeric string literals and __time in
APPROX_QUANTILE_FIXED_BUCKETS.
* Keep accepting NULL for the first operand of quantile aggregators
Add OperandType.nullable() so that a required operand can accept a NULL
literal, and use it for the six quantile aggregators. Add tests for NULL
and CAST(NULL AS DOUBLE) inputs.
* Compute nullable operands in buildNullableOperands and fill test gaps
buildNullableOperands takes the operand types now and includes operands
declared with OperandType.nullable(). Add numeric string literal and __time
tests for APPROX_QUANTILE and TDIGEST_QUANTILE, and the same inputs plus
BOOLEAN, ARRAY and complex inputs for DS_QUANTILES_SKETCH and
TDIGEST_GENERATE_SKETCH.
* Update TDigest __time quantile for t-digest 3.3
master upgraded t-digest to 3.3 in #20765, which changes the median of
__time in testQuantileAndSketchOnTimeColumn. The sketch is the same.
---
.../sql/TDigestGenerateSketchSqlAggregator.java | 6 +-
.../sql/TDigestSketchQuantileSqlAggregator.java | 7 +-
.../sql/TDigestSketchSqlAggregatorTest.java | 268 ++++++++++++++++
.../DoublesSketchApproxQuantileSqlAggregator.java | 8 +-
.../sql/DoublesSketchObjectSqlAggregator.java | 6 +-
.../sql/DoublesSketchSqlAggregatorTest.java | 188 +++++++++++
...FixedBucketsHistogramQuantileSqlAggregator.java | 14 +-
.../histogram/sql/QuantileSqlAggregator.java | 10 +-
...dBucketsHistogramQuantileSqlAggregatorTest.java | 208 ++++++++++++
.../histogram/sql/QuantileSqlAggregatorTest.java | 169 ++++++++++
.../sql/calcite/aggregation/Aggregations.java | 41 ++-
.../expression/DefaultOperandTypeChecker.java | 105 +++++-
.../druid/sql/calcite/expression/OperandType.java | 147 +++++++++
.../calcite/expression/OperatorConversions.java | 35 +-
.../apache/druid/sql/calcite/planner/Calcites.java | 28 ++
.../sql/calcite/planner/DruidTypeCoercion.java | 19 ++
.../apache/druid/sql/calcite/CalciteQueryTest.java | 21 ++
.../sql/calcite/aggregation/AggregationsTest.java | 118 +++++++
.../sql/calcite/expression/OperandTypeTest.java | 114 +++++++
.../expression/OperatorConversionsTest.java | 357 ++++++++++++++++++++-
.../AssertionErrorOperatorConversion.java | 3 +-
.../numeric_agg_string_args.iq | 167 ++++++++++
22 files changed, 2001 insertions(+), 38 deletions(-)
diff --git
a/extensions-contrib/tdigestsketch/src/main/java/org/apache/druid/query/aggregation/tdigestsketch/sql/TDigestGenerateSketchSqlAggregator.java
b/extensions-contrib/tdigestsketch/src/main/java/org/apache/druid/query/aggregation/tdigestsketch/sql/TDigestGenerateSketchSqlAggregator.java
index cb879df275d..936078cc15c 100644
---
a/extensions-contrib/tdigestsketch/src/main/java/org/apache/druid/query/aggregation/tdigestsketch/sql/TDigestGenerateSketchSqlAggregator.java
+++
b/extensions-contrib/tdigestsketch/src/main/java/org/apache/druid/query/aggregation/tdigestsketch/sql/TDigestGenerateSketchSqlAggregator.java
@@ -38,6 +38,7 @@ import org.apache.druid.sql.calcite.aggregation.Aggregations;
import org.apache.druid.sql.calcite.aggregation.SqlAggregator;
import org.apache.druid.sql.calcite.expression.DefaultOperandTypeChecker;
import org.apache.druid.sql.calcite.expression.DruidExpression;
+import org.apache.druid.sql.calcite.expression.OperandType;
import org.apache.druid.sql.calcite.planner.Calcites;
import org.apache.druid.sql.calcite.planner.PlannerContext;
import org.apache.druid.sql.calcite.rel.InputAccessor;
@@ -154,7 +155,10 @@ public class TDigestGenerateSketchSqlAggregator implements
SqlAggregator
DefaultOperandTypeChecker
.builder()
.operandNames("column", "compression")
- .operandTypes(SqlTypeFamily.ANY, SqlTypeFamily.NUMERIC)
+ .operandTypes(
+
OperandType.numeric().orComplex(TDigestSketchAggregatorFactory.TYPE).coerceStrings().nullable(),
+ OperandType.of(SqlTypeFamily.NUMERIC)
+ )
.requiredOperandCount(1)
.literalOperands(1)
.build(),
diff --git
a/extensions-contrib/tdigestsketch/src/main/java/org/apache/druid/query/aggregation/tdigestsketch/sql/TDigestSketchQuantileSqlAggregator.java
b/extensions-contrib/tdigestsketch/src/main/java/org/apache/druid/query/aggregation/tdigestsketch/sql/TDigestSketchQuantileSqlAggregator.java
index 613155e03c0..b780bf0f359 100644
---
a/extensions-contrib/tdigestsketch/src/main/java/org/apache/druid/query/aggregation/tdigestsketch/sql/TDigestSketchQuantileSqlAggregator.java
+++
b/extensions-contrib/tdigestsketch/src/main/java/org/apache/druid/query/aggregation/tdigestsketch/sql/TDigestSketchQuantileSqlAggregator.java
@@ -44,6 +44,7 @@ import org.apache.druid.sql.calcite.aggregation.Aggregations;
import org.apache.druid.sql.calcite.aggregation.SqlAggregator;
import org.apache.druid.sql.calcite.expression.DefaultOperandTypeChecker;
import org.apache.druid.sql.calcite.expression.DruidExpression;
+import org.apache.druid.sql.calcite.expression.OperandType;
import org.apache.druid.sql.calcite.planner.PlannerContext;
import org.apache.druid.sql.calcite.rel.InputAccessor;
import org.apache.druid.sql.calcite.rel.VirtualColumnRegistry;
@@ -181,7 +182,11 @@ public class TDigestSketchQuantileSqlAggregator implements
SqlAggregator
DefaultOperandTypeChecker
.builder()
.operandNames("column", "quantile", "compression")
- .operandTypes(SqlTypeFamily.ANY, SqlTypeFamily.NUMERIC,
SqlTypeFamily.NUMERIC)
+ .operandTypes(
+
OperandType.numeric().orComplex(TDigestSketchAggregatorFactory.TYPE).coerceStrings().nullable(),
+ OperandType.of(SqlTypeFamily.NUMERIC),
+ OperandType.of(SqlTypeFamily.NUMERIC)
+ )
.literalOperands(1, 2)
.requiredOperandCount(2)
.build(),
diff --git
a/extensions-contrib/tdigestsketch/src/test/java/org/apache/druid/query/aggregation/tdigestsketch/sql/TDigestSketchSqlAggregatorTest.java
b/extensions-contrib/tdigestsketch/src/test/java/org/apache/druid/query/aggregation/tdigestsketch/sql/TDigestSketchSqlAggregatorTest.java
index 48ae650c397..6e82d5caa37 100644
---
a/extensions-contrib/tdigestsketch/src/test/java/org/apache/druid/query/aggregation/tdigestsketch/sql/TDigestSketchSqlAggregatorTest.java
+++
b/extensions-contrib/tdigestsketch/src/test/java/org/apache/druid/query/aggregation/tdigestsketch/sql/TDigestSketchSqlAggregatorTest.java
@@ -584,6 +584,274 @@ public class TDigestSketchSqlAggregatorTest extends
BaseCalciteQueryTest
);
}
+ @Test
+ public void testQuantileAndSketchOnStringColumn()
+ {
+ // string columns are implicitly cast to numbers, like
testQuantileOnCastedString
+ cannotVectorize();
+
+ testQuery(
+ "SELECT\n"
+ + " TDIGEST_QUANTILE(dim1, 0.5),\n"
+ + " TDIGEST_GENERATE_SKETCH(dim1, 200)\n"
+ + "FROM foo",
+ ImmutableList.of(
+ Druids.newTimeseriesQueryBuilder()
+ .dataSource(CalciteTests.DATASOURCE1)
+ .intervals(querySegmentSpec(Filtration.eternity()))
+ .granularity(Granularities.ALL)
+ .virtualColumns(
+ new ExpressionVirtualColumn(
+ "v0",
+ "CAST(\"dim1\", 'DOUBLE')",
+ ColumnType.FLOAT,
+ ExprMacroTable.nil()
+ )
+ )
+ .aggregators(
+ new TDigestSketchAggregatorFactory("a0:agg", "v0", 100,
TDigestConfig.builder().build()),
+ new TDigestSketchAggregatorFactory("a1", "v0", 200,
TDigestConfig.builder().build())
+ )
+ .postAggregators(
+ new TDigestSketchToQuantilePostAggregator(
+ "a0",
+ new FieldAccessPostAggregator("a0:agg", "a0:agg"),
+ 0.5
+ )
+ )
+ .context(QUERY_CONTEXT_DEFAULT)
+ .build()
+ ),
+ ResultMatchMode.EQUALS_EPS,
+ ImmutableList.of(
+ new Object[]{
+ 2.0,
+
"\"AAAAAT/wAAAAAAAAQCQzMzMzMzNAaQAAAAAAAAAAAAM/8AAAAAAAAD/wAAAAAAAAP/AAAAAAAABAAAAAAAAAAD/wAAAAAAAAQCQzMzMzMzM=\""
+ }
+ )
+ );
+ }
+
+ @Test
+ public void testQuantileAndSketchOnNumericStringLiteral()
+ {
+ // string literals that can be parsed as numbers are accepted
+ cannotVectorize();
+
+ testQuery(
+ "SELECT\n"
+ + " TDIGEST_QUANTILE('1.5', 0.5),\n"
+ + " TDIGEST_GENERATE_SKETCH('1.5', 200)\n"
+ + "FROM foo",
+ ImmutableList.of(
+ Druids.newTimeseriesQueryBuilder()
+ .dataSource(CalciteTests.DATASOURCE1)
+ .intervals(querySegmentSpec(Filtration.eternity()))
+ .granularity(Granularities.ALL)
+ .virtualColumns(
+ new ExpressionVirtualColumn(
+ "v0",
+ "1.5",
+ ColumnType.FLOAT,
+ ExprMacroTable.nil()
+ )
+ )
+ .aggregators(
+ new TDigestSketchAggregatorFactory("a0:agg", "v0", 100,
TDigestConfig.builder().build()),
+ new TDigestSketchAggregatorFactory("a1", "v0", 200,
TDigestConfig.builder().build())
+ )
+ .postAggregators(
+ new TDigestSketchToQuantilePostAggregator(
+ "a0",
+ new FieldAccessPostAggregator("a0:agg", "a0:agg"),
+ 0.5
+ )
+ )
+ .context(QUERY_CONTEXT_DEFAULT)
+ .build()
+ ),
+ ResultMatchMode.EQUALS_EPS,
+ ImmutableList.of(
+ new Object[]{
+ 1.5,
+
"\"AAAAAT/4AAAAAAAAP/gAAAAAAABAaQAAAAAAAAAAAAY/8AAAAAAAAD/4AAAAAAAAP/AAAAAAAAA/+AAAAAAAAD/wAAAAAAAAP/gAAAAAAAA/8AAAAAAAAD/4AAAAAAAAP/AAAAAAAAA/+AAAAAAAAD/wAAAAAAAAP/gAAAAAAAA=\""
+ }
+ )
+ );
+ }
+
+ @Test
+ public void testQuantileAndSketchOnTimeColumn()
+ {
+ // timestamps are used as numbers of milliseconds
+ cannotVectorize();
+
+ testQuery(
+ "SELECT\n"
+ + " TDIGEST_QUANTILE(__time, 0.5),\n"
+ + " TDIGEST_GENERATE_SKETCH(__time, 200)\n"
+ + "FROM foo",
+ ImmutableList.of(
+ Druids.newTimeseriesQueryBuilder()
+ .dataSource(CalciteTests.DATASOURCE1)
+ .intervals(querySegmentSpec(Filtration.eternity()))
+ .granularity(Granularities.ALL)
+ .aggregators(
+ new TDigestSketchAggregatorFactory("a0:agg", "__time",
100, TDigestConfig.builder().build()),
+ new TDigestSketchAggregatorFactory("a1", "__time", 200,
TDigestConfig.builder().build())
+ )
+ .postAggregators(
+ new TDigestSketchToQuantilePostAggregator(
+ "a0",
+ new FieldAccessPostAggregator("a0:agg", "a0:agg"),
+ 0.5
+ )
+ )
+ .context(QUERY_CONTEXT_DEFAULT)
+ .build()
+ ),
+ ResultMatchMode.EQUALS_EPS,
+ ImmutableList.of(
+ new Object[]{
+ 978307200000.0,
+
"\"AAAAAUJrjVn1gAAAQmx6Pn2AAABAaQAAAAAAAAAAAAY/8AAAAAAAAEJrjVn1gAAAP/AAAAAAAABCa43+wQAAAD/wAAAAAAAAQmuOo4yAAAA/8AAAAAAAAEJsePTmgAAAP/AAAAAAAABCbHmZsgAAAD/wAAAAAAAAQmx6Pn2AAAA=\""
+ }
+ )
+ );
+ }
+
+ @Test
+ public void testQuantileAndSketchWithNonNumericStringLiteral()
+ {
+ // verify invalid queries return 400 (user error)
+ testQueryThrows(
+ "SELECT TDIGEST_QUANTILE('m1', 0.5) FROM foo",
+ invalidSqlContains(
+ "Function [TDIGEST_QUANTILE] argument [column] must be numeric,
got string literal ['m1']."
+ + " If you meant to refer to a column, use double quotes."
+ )
+ );
+ testQueryThrows(
+ "SELECT TDIGEST_GENERATE_SKETCH('m1') FROM foo",
+ invalidSqlContains(
+ "Function [TDIGEST_GENERATE_SKETCH] argument [column] must be
numeric, got string literal ['m1']."
+ + " If you meant to refer to a column, use double quotes."
+ )
+ );
+ }
+
+ @Test
+ public void testQuantileOnNull()
+ {
+ // NULL inputs are accepted, the same as before the first operand was type
checked
+ cannotVectorize();
+ testQuery(
+ "SELECT TDIGEST_QUANTILE(NULL, 0.5), TDIGEST_QUANTILE(CAST(NULL AS
DOUBLE), 0.5) FROM foo",
+ ImmutableList.of(
+ Druids.newTimeseriesQueryBuilder()
+ .dataSource(CalciteTests.DATASOURCE1)
+ .intervals(new
MultipleIntervalSegmentSpec(ImmutableList.of(Filtration.eternity())))
+ .granularity(Granularities.ALL)
+ .virtualColumns(
+ new ExpressionVirtualColumn(
+ "v0",
+ "null",
+ ColumnType.FLOAT,
+ ExprMacroTable.nil()
+ ),
+ new ExpressionVirtualColumn(
+ "v1",
+ "null",
+ ColumnType.FLOAT,
+ ExprMacroTable.nil()
+ )
+ )
+ .aggregators(ImmutableList.of(
+ new TDigestSketchAggregatorFactory("a0:agg", "v0", null,
TDigestConfig.builder().build()),
+ new TDigestSketchAggregatorFactory("a1:agg", "v1", null,
TDigestConfig.builder().build())
+ ))
+ .postAggregators(
+ new TDigestSketchToQuantilePostAggregator("a0",
makeFieldAccessPostAgg("a0:agg"), 0.5f),
+ new TDigestSketchToQuantilePostAggregator("a1",
makeFieldAccessPostAgg("a1:agg"), 0.5f)
+ )
+ .context(QUERY_CONTEXT_DEFAULT)
+ .build()
+ ),
+ ImmutableList.of(
+ new Object[]{Double.NaN, Double.NaN}
+ )
+ );
+ }
+
+ @Test
+ public void testGenerateSketchOnNull()
+ {
+ // NULL inputs are accepted, the same as before the first operand was type
checked
+ cannotVectorize();
+ testQuery(
+ "SELECT TDIGEST_GENERATE_SKETCH(NULL),
TDIGEST_GENERATE_SKETCH(CAST(NULL AS DOUBLE)) FROM foo",
+ ImmutableList.of(
+ Druids.newTimeseriesQueryBuilder()
+ .dataSource(CalciteTests.DATASOURCE1)
+ .intervals(new
MultipleIntervalSegmentSpec(ImmutableList.of(Filtration.eternity())))
+ .granularity(Granularities.ALL)
+ .virtualColumns(
+ new ExpressionVirtualColumn(
+ "v0",
+ "null",
+ ColumnType.FLOAT,
+ ExprMacroTable.nil()
+ ),
+ new ExpressionVirtualColumn(
+ "v1",
+ "null",
+ ColumnType.FLOAT,
+ ExprMacroTable.nil()
+ )
+ )
+ .aggregators(ImmutableList.of(
+ new TDigestSketchAggregatorFactory("a0", "v0", null,
TDigestConfig.builder().build()),
+ new TDigestSketchAggregatorFactory("a1", "v1", null,
TDigestConfig.builder().build())
+ ))
+ .context(QUERY_CONTEXT_DEFAULT)
+ .build()
+ ),
+ ImmutableList.of(
+ new Object[]{"\"AAAAAX/wAAAAAAAA//AAAAAAAABAWQAAAAAAAAAAAAA=\"",
"\"AAAAAX/wAAAAAAAA//AAAAAAAABAWQAAAAAAAAAAAAA=\""}
+ )
+ );
+ }
+
+ @Test
+ public void testQuantileAndSketchWithNonNumericInputTypes()
+ {
+ // verify invalid queries return 400 (user error)
+ testQueryThrows(
+ "SELECT TDIGEST_QUANTILE(m1 > 1, 0.5) FROM foo",
+ invalidSqlContains("Cannot apply 'TDIGEST_QUANTILE'")
+ );
+ testQueryThrows(
+ "SELECT TDIGEST_QUANTILE(ARRAY[1.0, 2.0], 0.5) FROM foo",
+ invalidSqlContains("Cannot apply 'TDIGEST_QUANTILE'")
+ );
+ testQueryThrows(
+ "SELECT TDIGEST_QUANTILE(PARSE_JSON('1'), 0.5) FROM foo",
+ invalidSqlContains("Cannot apply 'TDIGEST_QUANTILE'")
+ );
+ testQueryThrows(
+ "SELECT TDIGEST_GENERATE_SKETCH(m1 > 1) FROM foo",
+ invalidSqlContains("Cannot apply 'TDIGEST_GENERATE_SKETCH'")
+ );
+ testQueryThrows(
+ "SELECT TDIGEST_GENERATE_SKETCH(ARRAY[1.0, 2.0]) FROM foo",
+ invalidSqlContains("Cannot apply 'TDIGEST_GENERATE_SKETCH'")
+ );
+ testQueryThrows(
+ "SELECT TDIGEST_GENERATE_SKETCH(PARSE_JSON('1')) FROM foo",
+ invalidSqlContains("Cannot apply 'TDIGEST_GENERATE_SKETCH'")
+ );
+ }
+
private static PostAggregator makeFieldAccessPostAgg(String name)
{
return new FieldAccessPostAggregator(name, name);
diff --git
a/extensions-core/datasketches/src/main/java/org/apache/druid/query/aggregation/datasketches/quantiles/sql/DoublesSketchApproxQuantileSqlAggregator.java
b/extensions-core/datasketches/src/main/java/org/apache/druid/query/aggregation/datasketches/quantiles/sql/DoublesSketchApproxQuantileSqlAggregator.java
index 5038b722f7b..ccf3fe6aa93 100644
---
a/extensions-core/datasketches/src/main/java/org/apache/druid/query/aggregation/datasketches/quantiles/sql/DoublesSketchApproxQuantileSqlAggregator.java
+++
b/extensions-core/datasketches/src/main/java/org/apache/druid/query/aggregation/datasketches/quantiles/sql/DoublesSketchApproxQuantileSqlAggregator.java
@@ -32,6 +32,7 @@ import org.apache.druid.java.util.common.StringUtils;
import org.apache.druid.query.QueryContext;
import org.apache.druid.query.aggregation.AggregatorFactory;
import
org.apache.druid.query.aggregation.datasketches.quantiles.DoublesSketchAggregatorFactory;
+import
org.apache.druid.query.aggregation.datasketches.quantiles.DoublesSketchModule;
import
org.apache.druid.query.aggregation.datasketches.quantiles.DoublesSketchToQuantilePostAggregator;
import org.apache.druid.query.aggregation.post.FieldAccessPostAggregator;
import org.apache.druid.query.context.QueryContextParameters;
@@ -40,6 +41,7 @@ import org.apache.druid.sql.calcite.aggregation.Aggregation;
import org.apache.druid.sql.calcite.aggregation.Aggregations;
import org.apache.druid.sql.calcite.aggregation.SqlAggregator;
import org.apache.druid.sql.calcite.expression.DruidExpression;
+import org.apache.druid.sql.calcite.expression.OperandType;
import org.apache.druid.sql.calcite.expression.OperatorConversions;
import org.apache.druid.sql.calcite.parser.DruidSqlParserUtils;
import org.apache.druid.sql.calcite.planner.PlannerContext;
@@ -61,7 +63,11 @@ public class DoublesSketchApproxQuantileSqlAggregator
implements SqlAggregator
private static final SqlAggFunction FUNCTION_INSTANCE =
OperatorConversions.aggregatorBuilder(NAME)
.operandNames("column", "probability", "k")
- .operandTypes(SqlTypeFamily.ANY,
SqlTypeFamily.NUMERIC, SqlTypeFamily.EXACT_NUMERIC)
+ .operandTypes(
+
OperandType.numeric().orComplex(DoublesSketchModule.TYPE).coerceStrings().nullable(),
+ OperandType.of(SqlTypeFamily.NUMERIC),
+ OperandType.of(SqlTypeFamily.EXACT_NUMERIC)
+ )
.returnTypeNonNull(SqlTypeName.DOUBLE)
.requiredOperandCount(2)
.literalOperands(1, 2)
diff --git
a/extensions-core/datasketches/src/main/java/org/apache/druid/query/aggregation/datasketches/quantiles/sql/DoublesSketchObjectSqlAggregator.java
b/extensions-core/datasketches/src/main/java/org/apache/druid/query/aggregation/datasketches/quantiles/sql/DoublesSketchObjectSqlAggregator.java
index 55973c093d6..25b0f8d2194 100644
---
a/extensions-core/datasketches/src/main/java/org/apache/druid/query/aggregation/datasketches/quantiles/sql/DoublesSketchObjectSqlAggregator.java
+++
b/extensions-core/datasketches/src/main/java/org/apache/druid/query/aggregation/datasketches/quantiles/sql/DoublesSketchObjectSqlAggregator.java
@@ -36,6 +36,7 @@ import org.apache.druid.sql.calcite.aggregation.Aggregation;
import org.apache.druid.sql.calcite.aggregation.Aggregations;
import org.apache.druid.sql.calcite.aggregation.SqlAggregator;
import org.apache.druid.sql.calcite.expression.DruidExpression;
+import org.apache.druid.sql.calcite.expression.OperandType;
import org.apache.druid.sql.calcite.expression.OperatorConversions;
import org.apache.druid.sql.calcite.planner.Calcites;
import org.apache.druid.sql.calcite.planner.PlannerContext;
@@ -51,7 +52,10 @@ public class DoublesSketchObjectSqlAggregator implements
SqlAggregator
private static final SqlAggFunction FUNCTION_INSTANCE =
OperatorConversions.aggregatorBuilder(NAME)
.operandNames("column", "k")
- .operandTypes(SqlTypeFamily.ANY,
SqlTypeFamily.EXACT_NUMERIC)
+ .operandTypes(
+
OperandType.numeric().orComplex(DoublesSketchModule.TYPE).coerceStrings().nullable(),
+ OperandType.of(SqlTypeFamily.EXACT_NUMERIC)
+ )
.returnTypeInference(
Calcites.complexReturnTypeWithNullability(DoublesSketchModule.TYPE, false)
)
diff --git
a/extensions-core/datasketches/src/test/java/org/apache/druid/query/aggregation/datasketches/quantiles/sql/DoublesSketchSqlAggregatorTest.java
b/extensions-core/datasketches/src/test/java/org/apache/druid/query/aggregation/datasketches/quantiles/sql/DoublesSketchSqlAggregatorTest.java
index a40875b3fb7..83c45afebf8 100644
---
a/extensions-core/datasketches/src/test/java/org/apache/druid/query/aggregation/datasketches/quantiles/sql/DoublesSketchSqlAggregatorTest.java
+++
b/extensions-core/datasketches/src/test/java/org/apache/druid/query/aggregation/datasketches/quantiles/sql/DoublesSketchSqlAggregatorTest.java
@@ -1141,6 +1141,194 @@ public class DoublesSketchSqlAggregatorTest extends
BaseCalciteQueryTest
}
}
+ @Test
+ public void testApproxQuantileWithNonNumericStringLiteral()
+ {
+ // verify invalid queries return 400 (user error)
+ testQueryThrows(
+ "SELECT APPROX_QUANTILE_DS('m1', 0.99) FROM foo",
+ invalidSqlContains(
+ "Function [APPROX_QUANTILE_DS] argument [column] must be numeric,
got string literal ['m1']."
+ + " If you meant to refer to a column, use double quotes."
+ )
+ );
+ testQueryThrows(
+ "SELECT DS_QUANTILES_SKETCH('m1') FROM foo",
+ invalidSqlContains(
+ "Function [DS_QUANTILES_SKETCH] argument [column] must be numeric,
got string literal ['m1']."
+ + " If you meant to refer to a column, use double quotes."
+ )
+ );
+ }
+
+ @Test
+ public void testQuantileOnNull()
+ {
+ // NULL inputs are accepted, the same as before the first operand was type
checked
+ testQuery(
+ "SELECT APPROX_QUANTILE_DS(NULL, 0.5), APPROX_QUANTILE_DS(CAST(NULL AS
DOUBLE), 0.5), DS_QUANTILES_SKETCH(NULL), DS_QUANTILES_SKETCH(CAST(NULL AS
DOUBLE)) FROM foo",
+ ImmutableList.of(
+ Druids.newTimeseriesQueryBuilder()
+ .dataSource(CalciteTests.DATASOURCE1)
+ .intervals(new
MultipleIntervalSegmentSpec(ImmutableList.of(Filtration.eternity())))
+ .granularity(Granularities.ALL)
+ .virtualColumns(
+ new ExpressionVirtualColumn(
+ "v0",
+ "null",
+ ColumnType.FLOAT,
+ TestExprMacroTable.INSTANCE
+ )
+ )
+ .aggregators(ImmutableList.of(
+ new DoublesSketchAggregatorFactory("a0:agg", "v0", 128),
+ new DoublesSketchAggregatorFactory("a1", "v0", null,
null, false)
+ ))
+ .postAggregators(
+ new DoublesSketchToQuantilePostAggregator("a0",
makeFieldAccessPostAgg("a0:agg"), 0.5f)
+ )
+ .context(QUERY_CONTEXT_DEFAULT)
+ .build()
+ ),
+ ImmutableList.of(
+ new Object[]{Double.NaN, Double.NaN, "\"AQMIHoAAAAA=\"",
"\"AQMIHoAAAAA=\""}
+ )
+ );
+ }
+
+ @Test
+ public void testQuantileAndSketchWithNonNumericInputTypes()
+ {
+ // verify invalid queries return 400 (user error)
+ testQueryThrows(
+ "SELECT APPROX_QUANTILE_DS(m1 > 1, 0.99) FROM foo",
+ invalidSqlContains("Cannot apply 'APPROX_QUANTILE_DS'")
+ );
+ testQueryThrows(
+ "SELECT APPROX_QUANTILE_DS(ARRAY[1.0, 2.0], 0.99) FROM foo",
+ invalidSqlContains("Cannot apply 'APPROX_QUANTILE_DS'")
+ );
+ testQueryThrows(
+ "SELECT APPROX_QUANTILE_DS(PARSE_JSON('1'), 0.99) FROM foo",
+ invalidSqlContains("Cannot apply 'APPROX_QUANTILE_DS'")
+ );
+ testQueryThrows(
+ "SELECT DS_QUANTILES_SKETCH(m1 > 1) FROM foo",
+ invalidSqlContains("Cannot apply 'DS_QUANTILES_SKETCH'")
+ );
+ testQueryThrows(
+ "SELECT DS_QUANTILES_SKETCH(ARRAY[1.0, 2.0]) FROM foo",
+ invalidSqlContains("Cannot apply 'DS_QUANTILES_SKETCH'")
+ );
+ testQueryThrows(
+ "SELECT DS_QUANTILES_SKETCH(PARSE_JSON('1')) FROM foo",
+ invalidSqlContains("Cannot apply 'DS_QUANTILES_SKETCH'")
+ );
+ }
+
+ @Test
+ public void testQuantileOnStringColumn()
+ {
+ // string columns are implicitly cast to numbers, like
testQuantileOnCastedString
+ testQuery(
+ "SELECT APPROX_QUANTILE_DS(dim1, 0.5), DS_QUANTILES_SKETCH(dim1) FROM
foo",
+ ImmutableList.of(
+ Druids.newTimeseriesQueryBuilder()
+ .dataSource(CalciteTests.DATASOURCE1)
+ .intervals(new
MultipleIntervalSegmentSpec(ImmutableList.of(Filtration.eternity())))
+ .granularity(Granularities.ALL)
+ .virtualColumns(
+ new ExpressionVirtualColumn(
+ "v0",
+ "CAST(\"dim1\", 'DOUBLE')",
+ ColumnType.FLOAT,
+ TestExprMacroTable.INSTANCE
+ )
+ )
+ .aggregators(ImmutableList.of(
+ new DoublesSketchAggregatorFactory("a0:agg", "v0", 128),
+ new DoublesSketchAggregatorFactory("a1", "v0", null,
null, false)
+ ))
+ .postAggregators(
+ new DoublesSketchToQuantilePostAggregator("a0",
makeFieldAccessPostAgg("a0:agg"), 0.5f)
+ )
+ .context(QUERY_CONTEXT_DEFAULT)
+ .build()
+ ),
+ ImmutableList.of(
+ new Object[]{2.0,
"\"AgMIGoAAAAADAAAAAAAAAAAAAAAAAPA/MzMzMzMzJEAAAAAAAADwPwAAAAAAAABAMzMzMzMzJEA=\""}
+ )
+ );
+ }
+
+ @Test
+ public void testQuantileOnTimeColumn()
+ {
+ // timestamps are used as numbers of milliseconds
+ testQuery(
+ "SELECT APPROX_QUANTILE_DS(__time, 0.5), DS_QUANTILES_SKETCH(__time)
FROM foo",
+ ImmutableList.of(
+ Druids.newTimeseriesQueryBuilder()
+ .dataSource(CalciteTests.DATASOURCE1)
+ .intervals(new
MultipleIntervalSegmentSpec(ImmutableList.of(Filtration.eternity())))
+ .granularity(Granularities.ALL)
+ .aggregators(ImmutableList.of(
+ new DoublesSketchAggregatorFactory("a0:agg", "__time",
128),
+ new DoublesSketchAggregatorFactory("a1", "__time", null,
null, false)
+ ))
+ .postAggregators(
+ new DoublesSketchToQuantilePostAggregator("a0",
makeFieldAccessPostAgg("a0:agg"), 0.5f)
+ )
+ .context(QUERY_CONTEXT_DEFAULT)
+ .build()
+ ),
+ ImmutableList.of(
+ new Object[]{
+ 946857600000.0,
+
"\"AgMIGoAAAAAGAAAAAAAAAAAAgPVZjWtCAACAfT56bEIAAID1WY1rQgAAAMH+jWtCAACAjKOOa0IAAIDm9HhsQgAAALKZeWxCAACAfT56bEI=\""
+ }
+ )
+ );
+ }
+
+ @Test
+ public void testQuantileOnNumericStringLiteral()
+ {
+ // string literals that can be parsed as numbers are accepted
+ testQuery(
+ "SELECT APPROX_QUANTILE_DS('1.5', 0.5), DS_QUANTILES_SKETCH('1.5')
FROM foo",
+ ImmutableList.of(
+ Druids.newTimeseriesQueryBuilder()
+ .dataSource(CalciteTests.DATASOURCE1)
+ .intervals(new
MultipleIntervalSegmentSpec(ImmutableList.of(Filtration.eternity())))
+ .granularity(Granularities.ALL)
+ .virtualColumns(
+ new ExpressionVirtualColumn(
+ "v0",
+ "1.5",
+ ColumnType.FLOAT,
+ TestExprMacroTable.INSTANCE
+ )
+ )
+ .aggregators(ImmutableList.of(
+ new DoublesSketchAggregatorFactory("a0:agg", "v0", 128),
+ new DoublesSketchAggregatorFactory("a1", "v0", null,
null, false)
+ ))
+ .postAggregators(
+ new DoublesSketchToQuantilePostAggregator("a0",
makeFieldAccessPostAgg("a0:agg"), 0.5f)
+ )
+ .context(QUERY_CONTEXT_DEFAULT)
+ .build()
+ ),
+ ImmutableList.of(
+ new Object[]{
+ 1.5,
+
"\"AgMIGoAAAAAGAAAAAAAAAAAAAAAAAPg/AAAAAAAA+D8AAAAAAAD4PwAAAAAAAPg/AAAAAAAA+D8AAAAAAAD4PwAAAAAAAPg/AAAAAAAA+D8=\""
+ }
+ )
+ );
+ }
+
private static PostAggregator makeFieldAccessPostAgg(String name)
{
return new FieldAccessPostAggregator(name, name);
diff --git
a/extensions-core/histogram/src/main/java/org/apache/druid/query/aggregation/histogram/sql/FixedBucketsHistogramQuantileSqlAggregator.java
b/extensions-core/histogram/src/main/java/org/apache/druid/query/aggregation/histogram/sql/FixedBucketsHistogramQuantileSqlAggregator.java
index eceaa7b8ad2..952db351d37 100644
---
a/extensions-core/histogram/src/main/java/org/apache/druid/query/aggregation/histogram/sql/FixedBucketsHistogramQuantileSqlAggregator.java
+++
b/extensions-core/histogram/src/main/java/org/apache/druid/query/aggregation/histogram/sql/FixedBucketsHistogramQuantileSqlAggregator.java
@@ -33,6 +33,7 @@ import org.apache.calcite.util.Optionality;
import org.apache.druid.java.util.common.StringUtils;
import org.apache.druid.query.aggregation.AggregatorFactory;
import org.apache.druid.query.aggregation.histogram.FixedBucketsHistogram;
+import
org.apache.druid.query.aggregation.histogram.FixedBucketsHistogramAggregator;
import
org.apache.druid.query.aggregation.histogram.FixedBucketsHistogramAggregatorFactory;
import org.apache.druid.query.aggregation.histogram.QuantilePostAggregator;
import org.apache.druid.segment.column.ColumnType;
@@ -41,6 +42,7 @@ import org.apache.druid.sql.calcite.aggregation.Aggregations;
import org.apache.druid.sql.calcite.aggregation.SqlAggregator;
import org.apache.druid.sql.calcite.expression.DefaultOperandTypeChecker;
import org.apache.druid.sql.calcite.expression.DruidExpression;
+import org.apache.druid.sql.calcite.expression.OperandType;
import org.apache.druid.sql.calcite.planner.PlannerContext;
import org.apache.druid.sql.calcite.rel.InputAccessor;
import org.apache.druid.sql.calcite.rel.VirtualColumnRegistry;
@@ -243,12 +245,12 @@ public class FixedBucketsHistogramQuantileSqlAggregator
implements SqlAggregator
"outlierHandlingMode"
)
.operandTypes(
- SqlTypeFamily.ANY,
- SqlTypeFamily.NUMERIC,
- SqlTypeFamily.NUMERIC,
- SqlTypeFamily.NUMERIC,
- SqlTypeFamily.NUMERIC,
- SqlTypeFamily.STRING
+
OperandType.numeric().orComplex(FixedBucketsHistogramAggregator.TYPE).coerceStrings().nullable(),
+ OperandType.of(SqlTypeFamily.NUMERIC),
+ OperandType.of(SqlTypeFamily.NUMERIC),
+ OperandType.of(SqlTypeFamily.NUMERIC),
+ OperandType.of(SqlTypeFamily.NUMERIC),
+ OperandType.of(SqlTypeFamily.STRING)
)
.literalOperands(1, 2, 3, 4, 5)
.requiredOperandCount(5)
diff --git
a/extensions-core/histogram/src/main/java/org/apache/druid/query/aggregation/histogram/sql/QuantileSqlAggregator.java
b/extensions-core/histogram/src/main/java/org/apache/druid/query/aggregation/histogram/sql/QuantileSqlAggregator.java
index 5dc39247753..7524ac59395 100644
---
a/extensions-core/histogram/src/main/java/org/apache/druid/query/aggregation/histogram/sql/QuantileSqlAggregator.java
+++
b/extensions-core/histogram/src/main/java/org/apache/druid/query/aggregation/histogram/sql/QuantileSqlAggregator.java
@@ -43,6 +43,7 @@ import org.apache.druid.sql.calcite.aggregation.Aggregations;
import org.apache.druid.sql.calcite.aggregation.SqlAggregator;
import org.apache.druid.sql.calcite.expression.DefaultOperandTypeChecker;
import org.apache.druid.sql.calcite.expression.DruidExpression;
+import org.apache.druid.sql.calcite.expression.OperandType;
import org.apache.druid.sql.calcite.parser.DruidSqlParserUtils;
import org.apache.druid.sql.calcite.planner.PlannerContext;
import org.apache.druid.sql.calcite.rel.InputAccessor;
@@ -211,7 +212,14 @@ public class QuantileSqlAggregator implements SqlAggregator
DefaultOperandTypeChecker
.builder()
.operandNames("column", "probability", "resolution")
- .operandTypes(SqlTypeFamily.ANY, SqlTypeFamily.NUMERIC,
SqlTypeFamily.EXACT_NUMERIC)
+ .operandTypes(
+ OperandType.numeric()
+
.orComplex(ApproximateHistogramAggregatorFactory.TYPE)
+ .coerceStrings()
+ .nullable(),
+ OperandType.of(SqlTypeFamily.NUMERIC),
+ OperandType.of(SqlTypeFamily.EXACT_NUMERIC)
+ )
.requiredOperandCount(2)
.literalOperands(1, 2)
.build(),
diff --git
a/extensions-core/histogram/src/test/java/org/apache/druid/query/aggregation/histogram/sql/FixedBucketsHistogramQuantileSqlAggregatorTest.java
b/extensions-core/histogram/src/test/java/org/apache/druid/query/aggregation/histogram/sql/FixedBucketsHistogramQuantileSqlAggregatorTest.java
index 2268bb79c82..d4c100fc4a2 100644
---
a/extensions-core/histogram/src/test/java/org/apache/druid/query/aggregation/histogram/sql/FixedBucketsHistogramQuantileSqlAggregatorTest.java
+++
b/extensions-core/histogram/src/test/java/org/apache/druid/query/aggregation/histogram/sql/FixedBucketsHistogramQuantileSqlAggregatorTest.java
@@ -684,4 +684,212 @@ public class
FixedBucketsHistogramQuantileSqlAggregatorTest extends BaseCalciteQ
)
);
}
+
+ @Test
+ public void testQuantileOnStringColumn()
+ {
+ // string columns are implicitly cast to numbers, like
testQuantileOnCastedString
+ testQuery(
+ "SELECT APPROX_QUANTILE_FIXED_BUCKETS(dim1, 0.5, 20, 0.0, 10.0) FROM
foo",
+ ImmutableList.of(
+ Druids.newTimeseriesQueryBuilder()
+ .dataSource(CalciteTests.DATASOURCE1)
+ .intervals(new
MultipleIntervalSegmentSpec(ImmutableList.of(Filtration.eternity())))
+ .granularity(Granularities.ALL)
+ .virtualColumns(
+ new ExpressionVirtualColumn(
+ "v0",
+ "CAST(\"dim1\", 'DOUBLE')",
+ ColumnType.FLOAT,
+ TestExprMacroTable.INSTANCE
+ )
+ )
+ .aggregators(ImmutableList.of(
+ new FixedBucketsHistogramAggregatorFactory(
+ "a0:agg",
+ "v0",
+ 20,
+ 0.0d,
+ 10.0d,
+ FixedBucketsHistogram.OutlierHandlingMode.IGNORE,
+ false
+ )
+ ))
+ .postAggregators(
+ new QuantilePostAggregator("a0", "a0:agg", 0.5f)
+ )
+ .context(QUERY_CONTEXT_DEFAULT)
+ .build()
+ ),
+ ImmutableList.of(
+ new Object[]{1.5}
+ )
+ );
+ }
+
+ @Test
+ public void testQuantileOnNumericStringLiteral()
+ {
+ // string literals that can be parsed as numbers are accepted
+ testQuery(
+ "SELECT APPROX_QUANTILE_FIXED_BUCKETS('1.5', 0.5, 20, 0.0, 10.0) FROM
foo",
+ ImmutableList.of(
+ Druids.newTimeseriesQueryBuilder()
+ .dataSource(CalciteTests.DATASOURCE1)
+ .intervals(new
MultipleIntervalSegmentSpec(ImmutableList.of(Filtration.eternity())))
+ .granularity(Granularities.ALL)
+ .virtualColumns(
+ new ExpressionVirtualColumn(
+ "v0",
+ "1.5",
+ ColumnType.FLOAT,
+ TestExprMacroTable.INSTANCE
+ )
+ )
+ .aggregators(ImmutableList.of(
+ new FixedBucketsHistogramAggregatorFactory(
+ "a0:agg",
+ "v0",
+ 20,
+ 0.0d,
+ 10.0d,
+ FixedBucketsHistogram.OutlierHandlingMode.IGNORE,
+ false
+ )
+ ))
+ .postAggregators(
+ new QuantilePostAggregator("a0", "a0:agg", 0.5f)
+ )
+ .context(QUERY_CONTEXT_DEFAULT)
+ .build()
+ ),
+ ImmutableList.of(
+ new Object[]{1.75}
+ )
+ );
+ }
+
+ @Test
+ public void testQuantileOnTimeColumn()
+ {
+ // timestamps are used as numbers of milliseconds
+ testQuery(
+ "SELECT APPROX_QUANTILE_FIXED_BUCKETS(__time, 0.5, 20, 946684800000.0,
978393600000.0) FROM foo",
+ ImmutableList.of(
+ Druids.newTimeseriesQueryBuilder()
+ .dataSource(CalciteTests.DATASOURCE1)
+ .intervals(new
MultipleIntervalSegmentSpec(ImmutableList.of(Filtration.eternity())))
+ .granularity(Granularities.ALL)
+ .aggregators(ImmutableList.of(
+ new FixedBucketsHistogramAggregatorFactory(
+ "a0:agg",
+ "__time",
+ 20,
+ 946684800000.0d,
+ 978393600000.0d,
+ FixedBucketsHistogram.OutlierHandlingMode.IGNORE,
+ false
+ )
+ ))
+ .postAggregators(
+ new QuantilePostAggregator("a0", "a0:agg", 0.5f)
+ )
+ .context(QUERY_CONTEXT_DEFAULT)
+ .build()
+ ),
+ ImmutableList.of(
+ new Object[]{947741786112.0}
+ )
+ );
+ }
+
+ @Test
+ public void testQuantileWithNonNumericStringLiteral()
+ {
+ // verify invalid queries return 400 (user error)
+ testQueryThrows(
+ "SELECT APPROX_QUANTILE_FIXED_BUCKETS('m1', 0.99, 20, 0.0, 10.0) FROM
foo",
+ invalidSqlContains(
+ "Function [APPROX_QUANTILE_FIXED_BUCKETS] argument [column] must
be numeric, got string literal ['m1']."
+ + " If you meant to refer to a column, use double quotes."
+ )
+ );
+ }
+
+ @Test
+ public void testQuantileOnNull()
+ {
+ // NULL inputs are accepted, the same as before the first operand was type
checked
+ testQuery(
+ "SELECT APPROX_QUANTILE_FIXED_BUCKETS(NULL, 0.5, 20, 0.0, 10.0),
APPROX_QUANTILE_FIXED_BUCKETS(CAST(NULL AS DOUBLE), 0.5, 20, 0.0, 10.0) FROM
foo",
+ ImmutableList.of(
+ Druids.newTimeseriesQueryBuilder()
+ .dataSource(CalciteTests.DATASOURCE1)
+ .intervals(new
MultipleIntervalSegmentSpec(ImmutableList.of(Filtration.eternity())))
+ .granularity(Granularities.ALL)
+ .virtualColumns(
+ new ExpressionVirtualColumn(
+ "v0",
+ "null",
+ ColumnType.FLOAT,
+ TestExprMacroTable.INSTANCE
+ ),
+ new ExpressionVirtualColumn(
+ "v1",
+ "null",
+ ColumnType.FLOAT,
+ TestExprMacroTable.INSTANCE
+ )
+ )
+ .aggregators(ImmutableList.of(
+ new FixedBucketsHistogramAggregatorFactory(
+ "a0:agg",
+ "v0",
+ 20,
+ 0.0d,
+ 10.0d,
+ FixedBucketsHistogram.OutlierHandlingMode.IGNORE,
+ false
+ ),
+ new FixedBucketsHistogramAggregatorFactory(
+ "a1:agg",
+ "v1",
+ 20,
+ 0.0d,
+ 10.0d,
+ FixedBucketsHistogram.OutlierHandlingMode.IGNORE,
+ false
+ )
+ ))
+ .postAggregators(
+ new QuantilePostAggregator("a0", "a0:agg", 0.5f),
+ new QuantilePostAggregator("a1", "a1:agg", 0.5f)
+ )
+ .context(QUERY_CONTEXT_DEFAULT)
+ .build()
+ ),
+ ImmutableList.of(
+ new Object[]{0.0, 0.0}
+ )
+ );
+ }
+
+ @Test
+ public void testQuantileWithNonNumericInputTypes()
+ {
+ // verify invalid queries return 400 (user error)
+ final String expectedMessage = "Cannot apply
'APPROX_QUANTILE_FIXED_BUCKETS'";
+ testQueryThrows(
+ "SELECT APPROX_QUANTILE_FIXED_BUCKETS(m1 > 1, 0.99, 20, 0.0, 10.0)
FROM foo",
+ invalidSqlContains(expectedMessage)
+ );
+ testQueryThrows(
+ "SELECT APPROX_QUANTILE_FIXED_BUCKETS(ARRAY[1.0, 2.0], 0.99, 20, 0.0,
10.0) FROM foo",
+ invalidSqlContains(expectedMessage)
+ );
+ testQueryThrows(
+ "SELECT APPROX_QUANTILE_FIXED_BUCKETS(PARSE_JSON('1'), 0.99, 20, 0.0,
10.0) FROM foo",
+ invalidSqlContains(expectedMessage)
+ );
+ }
}
diff --git
a/extensions-core/histogram/src/test/java/org/apache/druid/query/aggregation/histogram/sql/QuantileSqlAggregatorTest.java
b/extensions-core/histogram/src/test/java/org/apache/druid/query/aggregation/histogram/sql/QuantileSqlAggregatorTest.java
index 4e46ff70d18..451f392eb81 100644
---
a/extensions-core/histogram/src/test/java/org/apache/druid/query/aggregation/histogram/sql/QuantileSqlAggregatorTest.java
+++
b/extensions-core/histogram/src/test/java/org/apache/druid/query/aggregation/histogram/sql/QuantileSqlAggregatorTest.java
@@ -491,4 +491,173 @@ public class QuantileSqlAggregatorTest extends
BaseCalciteQueryTest
)
);
}
+
+ @Test
+ public void testQuantileWithNonNumericStringLiteral()
+ {
+ // verify invalid queries return 400 (user error)
+ testQueryThrows(
+ "SELECT APPROX_QUANTILE('m1', 0.99) FROM foo",
+ invalidSqlContains(
+ "Function [APPROX_QUANTILE] argument [column] must be numeric, got
string literal ['m1']."
+ + " If you meant to refer to a column, use double quotes."
+ )
+ );
+ }
+
+ @Test
+ public void testQuantileOnNumericStringLiteral()
+ {
+ // string literals that can be parsed as numbers are accepted
+ testQuery(
+ "SELECT APPROX_QUANTILE('1.5', 0.5) FROM foo",
+ ImmutableList.of(
+ Druids.newTimeseriesQueryBuilder()
+ .dataSource(CalciteTests.DATASOURCE1)
+ .intervals(new
MultipleIntervalSegmentSpec(ImmutableList.of(Filtration.eternity())))
+ .granularity(Granularities.ALL)
+ .virtualColumns(
+ new ExpressionVirtualColumn(
+ "v0",
+ "1.5",
+ ColumnType.FLOAT,
+ TestExprMacroTable.INSTANCE
+ )
+ )
+ .aggregators(ImmutableList.of(
+ new ApproximateHistogramAggregatorFactory("a0:agg",
"v0", null, null, null, null, false)
+ ))
+ .postAggregators(
+ new QuantilePostAggregator("a0", "a0:agg", 0.5f)
+ )
+ .context(QUERY_CONTEXT_DEFAULT)
+ .build()
+ ),
+ ImmutableList.of(
+ new Object[]{1.5}
+ )
+ );
+ }
+
+ @Test
+ public void testQuantileOnTimeColumn()
+ {
+ // timestamps are used as numbers of milliseconds
+ testQuery(
+ "SELECT APPROX_QUANTILE(__time, 0.5) FROM foo",
+ ImmutableList.of(
+ Druids.newTimeseriesQueryBuilder()
+ .dataSource(CalciteTests.DATASOURCE1)
+ .intervals(new
MultipleIntervalSegmentSpec(ImmutableList.of(Filtration.eternity())))
+ .granularity(Granularities.ALL)
+ .aggregators(ImmutableList.of(
+ new ApproximateHistogramAggregatorFactory("a0:agg",
"__time", null, null, null, null, false)
+ ))
+ .postAggregators(
+ new QuantilePostAggregator("a0", "a0:agg", 0.5f)
+ )
+ .context(QUERY_CONTEXT_DEFAULT)
+ .build()
+ ),
+ ImmutableList.of(
+ new Object[]{946857574400.0}
+ )
+ );
+ }
+
+ @Test
+ public void testQuantileOnNull()
+ {
+ // NULL inputs are accepted, the same as before the first operand was type
checked
+ testQuery(
+ "SELECT APPROX_QUANTILE(NULL, 0.5), APPROX_QUANTILE(CAST(NULL AS
DOUBLE), 0.5) FROM foo",
+ ImmutableList.of(
+ Druids.newTimeseriesQueryBuilder()
+ .dataSource(CalciteTests.DATASOURCE1)
+ .intervals(new
MultipleIntervalSegmentSpec(ImmutableList.of(Filtration.eternity())))
+ .granularity(Granularities.ALL)
+ .virtualColumns(
+ new ExpressionVirtualColumn(
+ "v0",
+ "null",
+ ColumnType.FLOAT,
+ TestExprMacroTable.INSTANCE
+ ),
+ new ExpressionVirtualColumn(
+ "v1",
+ "null",
+ ColumnType.FLOAT,
+ TestExprMacroTable.INSTANCE
+ )
+ )
+ .aggregators(ImmutableList.of(
+ new ApproximateHistogramAggregatorFactory("a0:agg",
"v0", null, null, null, null, false),
+ new ApproximateHistogramAggregatorFactory("a1:agg",
"v1", null, null, null, null, false)
+ ))
+ .postAggregators(
+ new QuantilePostAggregator("a0", "a0:agg", 0.5f),
+ new QuantilePostAggregator("a1", "a1:agg", 0.5f)
+ )
+ .context(QUERY_CONTEXT_DEFAULT)
+ .build()
+ ),
+ ImmutableList.of(
+ new Object[]{Double.NaN, Double.NaN}
+ )
+ );
+ }
+
+ @Test
+ public void testQuantileWithNonNumericInputTypes()
+ {
+ // verify invalid queries return 400 (user error)
+ testQueryThrows(
+ "SELECT APPROX_QUANTILE(m1 > 1, 0.99) FROM foo",
+ invalidSqlContains("Cannot apply 'APPROX_QUANTILE'")
+ );
+ testQueryThrows(
+ "SELECT APPROX_QUANTILE(ARRAY[1.0, 2.0], 0.99) FROM foo",
+ invalidSqlContains("Cannot apply 'APPROX_QUANTILE'")
+ );
+ testQueryThrows(
+ "SELECT APPROX_QUANTILE(PARSE_JSON('1'), 0.99) FROM foo",
+ invalidSqlContains("Cannot apply 'APPROX_QUANTILE'")
+ );
+ }
+
+ @Test
+ public void testQuantileOnStringColumnWithConstantFilter()
+ {
+ // dim1 is known to be 'abc' at planning time, but it is a column, so it
must not be rejected like a string literal
+ testBuilder()
+ .sql("SELECT APPROX_QUANTILE(dim1, 0.5) FROM foo WHERE dim1 = 'abc'")
+ .expectedQueries(
+ ImmutableList.of(
+ Druids.newTimeseriesQueryBuilder()
+ .dataSource(CalciteTests.DATASOURCE1)
+ .intervals(new
MultipleIntervalSegmentSpec(ImmutableList.of(Filtration.eternity())))
+ .granularity(Granularities.ALL)
+ .virtualColumns(
+ new ExpressionVirtualColumn("v0", "null",
ColumnType.FLOAT, ExprMacroTable.nil())
+ )
+ .filters(equality("dim1", "abc", ColumnType.STRING))
+ .aggregators(
+ new ApproximateHistogramAggregatorFactory(
+ "a0:agg",
+ "v0",
+ 50,
+ 7,
+ Float.NEGATIVE_INFINITY,
+ Float.POSITIVE_INFINITY,
+ false
+ )
+ )
+ .postAggregators(new QuantilePostAggregator("a0",
"a0:agg", 0.5f))
+ .context(QUERY_CONTEXT_DEFAULT)
+ .build()
+ )
+ )
+ .expectedResults(ImmutableList.of(new Object[]{Double.NaN}))
+ .run();
+ }
}
diff --git
a/sql/src/main/java/org/apache/druid/sql/calcite/aggregation/Aggregations.java
b/sql/src/main/java/org/apache/druid/sql/calcite/aggregation/Aggregations.java
index 5c06332a9bc..6f5ccecbed8 100644
---
a/sql/src/main/java/org/apache/druid/sql/calcite/aggregation/Aggregations.java
+++
b/sql/src/main/java/org/apache/druid/sql/calcite/aggregation/Aggregations.java
@@ -20,11 +20,15 @@
package org.apache.druid.sql.calcite.aggregation;
import org.apache.calcite.rel.core.AggregateCall;
+import org.apache.calcite.rex.RexLiteral;
import org.apache.calcite.rex.RexNode;
+import org.apache.calcite.sql.type.SqlTypeFamily;
+import org.apache.druid.error.InvalidSqlInput;
import org.apache.druid.segment.column.RowSignature;
import org.apache.druid.segment.column.ValueType;
import org.apache.druid.sql.calcite.expression.DruidExpression;
import org.apache.druid.sql.calcite.expression.Expressions;
+import org.apache.druid.sql.calcite.planner.Calcites;
import org.apache.druid.sql.calcite.planner.PlannerContext;
import org.apache.druid.sql.calcite.rel.InputAccessor;
@@ -47,6 +51,10 @@ public class Aggregations
*
* 1) They can take direct field accesses or expressions as inputs.
* 2) They cannot implicitly cast strings to numbers when using a direct
field access.
+ *
+ * Unlike {@link #toDruidExpressionForNumericAggregator}, this does not
reject string literals, because it is also
+ * used by aggregators that accept strings, such as COUNT and SINGLE_VALUE.
+ *
* @param plannerContext SQL planner context
* @param call aggregate call object
* @param inputAccessor gives access to input fields and schema
@@ -64,7 +72,7 @@ public class Aggregations
.getArgList()
.stream()
.map(i -> inputAccessor.getField(i))
- .map(rexNode -> toDruidExpressionForNumericAggregator(plannerContext,
inputAccessor.getInputRowSignature(), rexNode))
+ .map(rexNode -> toDruidExpressionWithStringColumnCast(plannerContext,
inputAccessor.getInputRowSignature(), rexNode))
.collect(Collectors.toList());
if (args.stream().noneMatch(Objects::isNull)) {
@@ -82,6 +90,10 @@ public class Aggregations
* Consider using {@link Expressions#toDruidExpression(PlannerContext,
RowSignature, RexNode)} for projections
* or the aggregators that don't require numeric inputs.
*
+ * String literals are converted to numbers the same way as string columns,
but since their value is known at
+ * planning time, a string literal that cannot be parsed as a number is
rejected with an {@link InvalidSqlInput}
+ * error instead of silently producing nulls at runtime.
+ *
* @param plannerContext SQL planner context
* @param rowSignature signature of the rows to be extracted from
* @param rexNode expression meant to be applied on top of the rows
@@ -93,6 +105,17 @@ public class Aggregations
final RowSignature rowSignature,
final RexNode rexNode
)
+ {
+ validateStringLiteralIsNumeric(rexNode);
+ return toDruidExpressionWithStringColumnCast(plannerContext, rowSignature,
rexNode);
+ }
+
+ @Nullable
+ private static DruidExpression toDruidExpressionWithStringColumnCast(
+ final PlannerContext plannerContext,
+ final RowSignature rowSignature,
+ final RexNode rexNode
+ )
{
final DruidExpression druidExpression =
Expressions.toDruidExpression(plannerContext, rowSignature, rexNode);
if (druidExpression == null) {
@@ -109,4 +132,20 @@ public class Aggregations
return druidExpression;
}
}
+
+ /**
+ * Throws an {@link InvalidSqlInput} error if the given node is a non-null
string literal that cannot be parsed as a
+ * number. See {@link Calcites#isNumericString(String)}.
+ */
+ private static void validateStringLiteralIsNumeric(final RexNode rexNode)
+ {
+ if (!(rexNode instanceof RexLiteral) ||
!SqlTypeFamily.CHARACTER.contains(rexNode.getType())) {
+ return;
+ }
+
+ final String value = ((RexLiteral) rexNode).getValueAs(String.class);
+ if (value != null && !Calcites.isNumericString(value)) {
+ throw Calcites.nonNumericStringLiteralError("Aggregator input", value);
+ }
+ }
}
diff --git
a/sql/src/main/java/org/apache/druid/sql/calcite/expression/DefaultOperandTypeChecker.java
b/sql/src/main/java/org/apache/druid/sql/calcite/expression/DefaultOperandTypeChecker.java
index a52ce5707c1..3206f080aed 100644
---
a/sql/src/main/java/org/apache/druid/sql/calcite/expression/DefaultOperandTypeChecker.java
+++
b/sql/src/main/java/org/apache/druid/sql/calcite/expression/DefaultOperandTypeChecker.java
@@ -25,6 +25,7 @@ import it.unimi.dsi.fastutil.ints.IntSet;
import it.unimi.dsi.fastutil.ints.IntSets;
import org.apache.calcite.rel.type.RelDataType;
import org.apache.calcite.sql.SqlCallBinding;
+import org.apache.calcite.sql.SqlCharStringLiteral;
import org.apache.calcite.sql.SqlNode;
import org.apache.calcite.sql.SqlOperandCountRange;
import org.apache.calcite.sql.SqlOperator;
@@ -33,8 +34,12 @@ import org.apache.calcite.sql.type.SqlOperandCountRanges;
import org.apache.calcite.sql.type.SqlOperandTypeChecker;
import org.apache.calcite.sql.type.SqlTypeFamily;
import org.apache.calcite.sql.type.SqlTypeName;
+import org.apache.calcite.sql.validate.implicit.TypeCoercion;
import org.apache.calcite.util.Static;
import org.apache.druid.java.util.common.ISE;
+import org.apache.druid.java.util.common.StringUtils;
+import org.apache.druid.sql.calcite.planner.Calcites;
+import org.apache.druid.sql.calcite.planner.DruidTypeCoercion;
import javax.annotation.Nullable;
import java.util.Arrays;
@@ -53,14 +58,14 @@ public class DefaultOperandTypeChecker implements
SqlOperandTypeChecker
* {@link #operandTypes} are used instead.
*/
private final List<String> operandNames;
- private final List<SqlTypeFamily> operandTypes;
+ private final List<OperandType> operandTypes;
private final int requiredOperands;
private final IntSet nullableOperands;
private final IntSet literalOperands;
private DefaultOperandTypeChecker(
final List<String> operandNames,
- final List<SqlTypeFamily> operandTypes,
+ final List<OperandType> operandTypes,
final int requiredOperands,
final IntSet nullableOperands,
@Nullable final int[] literalOperands
@@ -110,13 +115,40 @@ public class DefaultOperandTypeChecker implements
SqlOperandTypeChecker
}
}
+ final OperandType expectedType = operandTypes.get(i);
+ if (expectedType.isCoerceStrings() && operand instanceof
SqlCharStringLiteral) {
+ // Verify that a string literal can be converted to a number. This
must happen before coercion, which would
+ // otherwise turn it into a null number. Explicit CASTs are not
checked, since they are the user's intent.
+ final String value = ((SqlCharStringLiteral)
operand).getValueAs(String.class);
+ if (!Calcites.isNumericString(value)) {
+ if (throwOnFailure) {
+ throw Calcites.nonNumericStringLiteralError(
+ StringUtils.format(
+ "Function [%s] argument [%s]",
+ callBinding.getOperator().getName(),
+ operandNames.isEmpty() ? i + 1 : operandNames.get(i)
+ ),
+ value
+ );
+ } else {
+ return false;
+ }
+ }
+ }
+
final RelDataType operandType =
callBinding.getValidator().deriveType(callBinding.getScope(), operand);
- final SqlTypeFamily expectedFamily = operandTypes.get(i);
+ final SqlTypeFamily expectedFamily = expectedType.getFamily();
if (expectedFamily == SqlTypeFamily.ANY) {
// ANY matches anything. This operand is all good; do nothing.
} else if
(expectedFamily.getTypeNames().contains(operandType.getSqlTypeName())) {
// Operand came in with one of the expected types.
+ } else if (expectedType.isAllowedComplexType(operandType)) {
+ // Operand came in with one of the complex types allowed for this
operand.
+ } else if (expectedType.isCoerceStrings() &&
SqlTypeFamily.DATETIME.contains(operandType)) {
+ // Operand came in with a datetime type, which Druid stores as a
number of milliseconds.
+ } else if (expectedType.isCoerceStrings() && coerceToNumber(callBinding,
i, operandType)) {
+ // Operand was a character string, and is now cast to a number.
} else if (operandType.getSqlTypeName() == SqlTypeName.NULL ||
SqlUtil.isNullLiteral(operand, true)) {
// Null came in, check if operand is a nullable type.
if (!nullableOperands.contains(i)) {
@@ -138,6 +170,37 @@ public class DefaultOperandTypeChecker implements
SqlOperandTypeChecker
return true;
}
+ /**
+ * Casts a character string operand to DOUBLE using implicit type coercion.
Returns false, without changing the
+ * operand, if the operand is not a character string or if type coercion is
not available.
+ */
+ private static boolean coerceToNumber(
+ final SqlCallBinding callBinding,
+ final int operand,
+ final RelDataType operandType
+ )
+ {
+ if (!SqlTypeFamily.CHARACTER.contains(operandType) ||
!callBinding.isTypeCoercionEnabled()) {
+ return false;
+ }
+
+ final TypeCoercion typeCoercion =
callBinding.getValidator().getTypeCoercion();
+ if (!(typeCoercion instanceof DruidTypeCoercion)) {
+ return false;
+ }
+
+ return ((DruidTypeCoercion) typeCoercion).coerceOperand(
+ callBinding.getScope(),
+ callBinding.getCall(),
+ operand,
+ Calcites.createSqlTypeWithNullability(
+ callBinding.getTypeFactory(),
+ SqlTypeName.DOUBLE,
+ operandType.isNullable()
+ )
+ );
+ }
+
@Override
public SqlOperandCountRange getOperandCountRange()
{
@@ -183,7 +246,7 @@ public class DefaultOperandTypeChecker implements
SqlOperandTypeChecker
public static class Builder
{
private List<String> operandNames = Collections.emptyList();
- private List<SqlTypeFamily> operandTypes;
+ private List<OperandType> operandTypes;
@Nullable
private Integer requiredOperandCount;
@@ -206,16 +269,24 @@ public class DefaultOperandTypeChecker implements
SqlOperandTypeChecker
return this;
}
- public Builder operandTypes(final SqlTypeFamily... operandTypes)
+ /**
+ * Sets the operand types, which may accept complex types or coerce
strings, such as
+ * {@code OperandType.numeric().orComplex(type).coerceStrings()}.
+ */
+ public Builder operandTypes(final OperandType... operandTypes)
{
this.operandTypes = Arrays.asList(operandTypes);
return this;
}
+ public Builder operandTypes(final SqlTypeFamily... operandTypes)
+ {
+ return
operandTypes(Arrays.stream(operandTypes).map(OperandType::of).toArray(OperandType[]::new));
+ }
+
public Builder operandTypes(final List<SqlTypeFamily> operandTypes)
{
- this.operandTypes = operandTypes;
- return this;
+ return
operandTypes(operandTypes.stream().map(OperandType::of).toArray(OperandType[]::new));
}
public Builder requiredOperandCount(Integer requiredOperandCount)
@@ -243,16 +314,30 @@ public class DefaultOperandTypeChecker implements
SqlOperandTypeChecker
operandNames,
operandTypes,
computedRequiredOperandCount,
-
DefaultOperandTypeChecker.buildNullableOperands(computedRequiredOperandCount,
operandTypes.size(), notNullOperands),
+
DefaultOperandTypeChecker.buildNullableOperands(computedRequiredOperandCount,
operandTypes, notNullOperands),
literalOperands
);
}
}
- public static IntSet buildNullableOperands(int requiredOperandCount, int
totalOperandCount, IntSet notNullOperands)
+ /**
+ * Returns the indexes of operands that accept NULL literals: optional
operands, and operands declared with
+ * {@link OperandType#nullable()}. Operands in notNullOperands are always
excluded, even if they are optional or
+ * nullable.
+ *
+ * @param requiredOperandCount number of required operands, at most the
number of operand types
+ * @param operandTypes types of all operands
+ * @param notNullOperands indexes of operands that never accept NULL
literals
+ */
+ public static IntSet buildNullableOperands(
+ int requiredOperandCount,
+ List<OperandType> operandTypes,
+ IntSet notNullOperands
+ )
{
final IntSet nullableOperands = new IntArraySet();
- IntStream.range(requiredOperandCount, totalOperandCount)
+ IntStream.range(0, operandTypes.size())
+ .filter(i -> i >= requiredOperandCount ||
operandTypes.get(i).isNullable())
.filter(i -> !notNullOperands.contains(i))
.forEach(nullableOperands::add);
return nullableOperands;
diff --git
a/sql/src/main/java/org/apache/druid/sql/calcite/expression/OperandType.java
b/sql/src/main/java/org/apache/druid/sql/calcite/expression/OperandType.java
new file mode 100644
index 00000000000..40f430e3c4c
--- /dev/null
+++ b/sql/src/main/java/org/apache/druid/sql/calcite/expression/OperandType.java
@@ -0,0 +1,147 @@
+/*
+ * 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.sql.calcite.expression;
+
+import com.google.common.base.Preconditions;
+import com.google.common.collect.ImmutableSet;
+import org.apache.calcite.rel.type.RelDataType;
+import org.apache.calcite.sql.type.SqlTypeFamily;
+import org.apache.druid.error.DruidException;
+import org.apache.druid.segment.column.ColumnType;
+import org.apache.druid.sql.calcite.table.RowSignatures;
+
+/**
+ * Type of one operand of a {@link DefaultOperandTypeChecker}: a {@link
SqlTypeFamily}, optionally with complex types
+ * that are accepted too, whether NULL literals are accepted, and for {@link
SqlTypeFamily#NUMERIC} operands, whether
+ * character strings are accepted by casting them to numbers. Instances are
immutable.
+ */
+public final class OperandType
+{
+ private final SqlTypeFamily family;
+ private final ImmutableSet<ColumnType> complexTypes;
+ private final boolean coerceStrings;
+ private final boolean nullable;
+
+ private OperandType(
+ final SqlTypeFamily family,
+ final ImmutableSet<ColumnType> complexTypes,
+ final boolean coerceStrings,
+ final boolean nullable
+ )
+ {
+ this.family = Preconditions.checkNotNull(family, "family");
+ this.complexTypes = complexTypes;
+ this.coerceStrings = coerceStrings;
+ this.nullable = nullable;
+ }
+
+ public static OperandType of(final SqlTypeFamily family)
+ {
+ return new OperandType(family, ImmutableSet.of(), false, false);
+ }
+
+ public static OperandType numeric()
+ {
+ return of(SqlTypeFamily.NUMERIC);
+ }
+
+ /**
+ * Returns an operand type that also accepts the given complex types.
Comparison uses
+ * {@link RowSignatures.ComplexSqlType#getColumnType()}, so it does not
depend on nullability. Not allowed for
+ * {@link SqlTypeFamily#ANY} operands, which accept complex types already.
+ */
+ public OperandType orComplex(final ColumnType... types)
+ {
+ if (family == SqlTypeFamily.ANY) {
+ throw DruidException.defensive("orComplex() does not apply to [%s]
operands", SqlTypeFamily.ANY);
+ }
+ return new OperandType(
+ family,
+
ImmutableSet.<ColumnType>builder().addAll(complexTypes).add(types).build(),
+ coerceStrings,
+ nullable
+ );
+ }
+
+ /**
+ * Returns a {@link SqlTypeFamily#NUMERIC} operand type that also accepts
character strings, such as string
+ * columns, by casting them to DOUBLE. String literals that cannot be parsed
as numbers are rejected, since they are
+ * usually column names written in single quotes instead of double quotes.
Datetime operands, such as
+ * {@code __time}, are also accepted without a cast, since Druid stores them
as numbers of milliseconds.
+ */
+ public OperandType coerceStrings()
+ {
+ if (family != SqlTypeFamily.NUMERIC) {
+ throw DruidException.defensive(
+ "coerceStrings() only applies to [%s] operands, got [%s]",
+ SqlTypeFamily.NUMERIC,
+ family
+ );
+ }
+ return new OperandType(family, complexTypes, true, nullable);
+ }
+
+ /**
+ * Returns an operand type that also accepts NULL literals, even if the
operand is required. Optional operands
+ * accept NULL literals already.
+ */
+ public OperandType nullable()
+ {
+ return new OperandType(family, complexTypes, coerceStrings, true);
+ }
+
+ public SqlTypeFamily getFamily()
+ {
+ return family;
+ }
+
+ public boolean isCoerceStrings()
+ {
+ return coerceStrings;
+ }
+
+ public boolean isNullable()
+ {
+ return nullable;
+ }
+
+ /**
+ * Whether the type is one of the complex types given to {@link
#orComplex(ColumnType...)}.
+ */
+ public boolean isAllowedComplexType(final RelDataType type)
+ {
+ return type instanceof RowSignatures.ComplexSqlType
+ && complexTypes.contains(((RowSignatures.ComplexSqlType)
type).getColumnType());
+ }
+
+ /**
+ * Used by {@link DefaultOperandTypeChecker#getAllowedSignatures} if the
operands have no names, e.g.
+ * {@code NUMERIC | COMPLEX<tdigest>}.
+ */
+ @Override
+ public String toString()
+ {
+ final StringBuilder sb = new StringBuilder(family.toString());
+ for (final ColumnType type : complexTypes) {
+ sb.append(" | ").append(type);
+ }
+ return sb.toString();
+ }
+}
diff --git
a/sql/src/main/java/org/apache/druid/sql/calcite/expression/OperatorConversions.java
b/sql/src/main/java/org/apache/druid/sql/calcite/expression/OperatorConversions.java
index 450d6208240..3c0e72717b8 100644
---
a/sql/src/main/java/org/apache/druid/sql/calcite/expression/OperatorConversions.java
+++
b/sql/src/main/java/org/apache/druid/sql/calcite/expression/OperatorConversions.java
@@ -345,7 +345,7 @@ public class OperatorConversions
// For operand type checking
private SqlOperandTypeChecker operandTypeChecker;
private List<String> operandNames = Collections.emptyList();
- private List<SqlTypeFamily> operandTypes;
+ private List<OperandType> operandTypes;
private Integer requiredOperandCount;
private int[] literalOperands;
private SqlOperandTypeInference operandTypeInference;
@@ -471,7 +471,8 @@ public class OperatorConversions
/**
* Provide customized operand type checking logic.
*
- * Either {@link #operandTypes(SqlTypeFamily...)} or this method must be
used before calling {@link #build()}.
+ * Either {@link #operandTypes(SqlTypeFamily...)}, {@link
#operandTypes(OperandType...)} or this method must be used
+ * before calling {@link #build()}.
* These methods cannot be mixed; you must call exactly one.
*/
public OperatorBuilder<T> operandTypeChecker(final SqlOperandTypeChecker
operandTypeChecker)
@@ -501,6 +502,16 @@ public class OperatorConversions
* For deeper control, use {@link
#operandTypeChecker(SqlOperandTypeChecker)} instead.
*/
public OperatorBuilder<T> operandTypes(final SqlTypeFamily... operandTypes)
+ {
+ return
operandTypes(Arrays.stream(operandTypes).map(OperandType::of).toArray(OperandType[]::new));
+ }
+
+ /**
+ * Equivalent to calling {@link
DefaultOperandTypeChecker.Builder#operandTypes(OperandType...)}. Like
+ * {@link #operandTypes(SqlTypeFamily...)}, but the operand types may
accept complex types or coerce strings, such
+ * as {@code OperandType.numeric().orComplex(type).coerceStrings()}.
+ */
+ public OperatorBuilder<T> operandTypes(final OperandType... operandTypes)
{
this.operandTypes = Arrays.asList(operandTypes);
return this;
@@ -572,8 +583,8 @@ public class OperatorConversions
return DefaultOperandTypeChecker
.builder()
.operandNames(operandNames)
- .operandTypes(operandTypes)
- .requiredOperandCount(requiredOperandCount == null ?
operandTypes.size() : requiredOperandCount)
+ .operandTypes(operandTypes.toArray(new OperandType[0]))
+ .requiredOperandCount(getRequiredOperandCount())
.literalOperands(literalOperands)
.build();
} else if (operandNames.isEmpty()
@@ -589,11 +600,17 @@ public class OperatorConversions
}
}
+ private int getRequiredOperandCount()
+ {
+ return requiredOperandCount == null ? operandTypes.size() :
requiredOperandCount;
+ }
+
protected SqlOperandTypeInference buildOperandTypeInference()
{
- final IntSet nullableOperands = requiredOperandCount == null
+ // operandTypes is null if an operandTypeChecker was passed in instead,
e.g. GREATEST and LEAST.
+ final IntSet nullableOperands = operandTypes == null
? new IntArraySet()
- :
DefaultOperandTypeChecker.buildNullableOperands(requiredOperandCount,
operandTypes.size(), new IntArraySet());
+ :
DefaultOperandTypeChecker.buildNullableOperands(getRequiredOperandCount(),
operandTypes, new IntArraySet());
if (operandTypeInference == null) {
SqlOperandTypeInference defaultInference = new
DefaultOperandTypeInference(operandTypes, nullableOperands);
return (callBinding, returnType, types) -> {
@@ -685,10 +702,10 @@ public class OperatorConversions
*/
private static class DefaultOperandTypeInference implements
SqlOperandTypeInference
{
- private final List<SqlTypeFamily> operandTypes;
+ private final List<OperandType> operandTypes;
private final IntSet nullableOperands;
- DefaultOperandTypeInference(final List<SqlTypeFamily> operandTypes, final
IntSet nullableOperands)
+ DefaultOperandTypeInference(final List<OperandType> operandTypes, final
IntSet nullableOperands)
{
this.operandTypes = operandTypes;
this.nullableOperands = nullableOperands;
@@ -715,7 +732,7 @@ public class OperatorConversions
if (nullableOperands.contains(i)) {
inferredType = Calcites.createSqlTypeWithNullability(
callBinding.getTypeFactory(),
- defaultTypeForFamily(operandTypes.get(i)),
+ defaultTypeForFamily(operandTypes.get(i).getFamily()),
true
);
} else {
diff --git
a/sql/src/main/java/org/apache/druid/sql/calcite/planner/Calcites.java
b/sql/src/main/java/org/apache/druid/sql/calcite/planner/Calcites.java
index c0c6b3cb44c..88a8c03afe3 100644
--- a/sql/src/main/java/org/apache/druid/sql/calcite/planner/Calcites.java
+++ b/sql/src/main/java/org/apache/druid/sql/calcite/planner/Calcites.java
@@ -41,8 +41,10 @@ import org.apache.calcite.util.DateString;
import org.apache.calcite.util.TimeString;
import org.apache.calcite.util.TimestampString;
import org.apache.druid.error.DruidException;
+import org.apache.druid.error.InvalidSqlInput;
import org.apache.druid.java.util.common.DateTimes;
import org.apache.druid.java.util.common.StringUtils;
+import org.apache.druid.math.expr.ExprEval;
import org.apache.druid.math.expr.ExpressionProcessing;
import org.apache.druid.math.expr.ExpressionProcessingConfig;
import org.apache.druid.query.ordering.StringComparator;
@@ -527,6 +529,32 @@ public class Calcites
return StringUtils.format("%s:%s", prefix, suffix);
}
+ /**
+ * Returns whether a string literal can be used where a number is expected.
The parsing follows
+ * {@link ExprEval#computeNumber(String)}, which is how strings are
implicitly converted to numbers at runtime.
+ */
+ public static boolean isNumericString(final String value)
+ {
+ return ExprEval.computeNumber(value) != null;
+ }
+
+ /**
+ * Creates the error for a string literal that is used where a number is
expected, but cannot be parsed as one
+ * (see {@link #isNumericString(String)}). Such a literal is often a column
name written in single quotes instead of
+ * double quotes.
+ *
+ * @param target where the literal is used, such as "Function
[APPROX_QUANTILE] argument [column]"
+ * @param value value of the literal
+ */
+ public static DruidException nonNumericStringLiteralError(final String
target, final String value)
+ {
+ return InvalidSqlInput.exception(
+ "%s must be numeric, got string literal ['%s']. If you meant to refer
to a column, use double quotes.",
+ target,
+ value
+ );
+ }
+
public static Class<?> sqlTypeNameJdbcToJavaClass(SqlTypeName typeName)
{
// reference:
https://docs.oracle.com/javase/1.5.0/docs/guide/jdbc/getstart/mapping.html
diff --git
a/sql/src/main/java/org/apache/druid/sql/calcite/planner/DruidTypeCoercion.java
b/sql/src/main/java/org/apache/druid/sql/calcite/planner/DruidTypeCoercion.java
index 699f32b09c4..787c9244346 100644
---
a/sql/src/main/java/org/apache/druid/sql/calcite/planner/DruidTypeCoercion.java
+++
b/sql/src/main/java/org/apache/druid/sql/calcite/planner/DruidTypeCoercion.java
@@ -23,10 +23,12 @@ import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableSet;
import org.apache.calcite.rel.type.RelDataType;
import org.apache.calcite.rel.type.RelDataTypeFactory;
+import org.apache.calcite.sql.SqlCall;
import org.apache.calcite.sql.type.SqlTypeCoercionRule;
import org.apache.calcite.sql.type.SqlTypeName;
import org.apache.calcite.sql.type.SqlTypeUtil;
import org.apache.calcite.sql.validate.SqlValidator;
+import org.apache.calcite.sql.validate.SqlValidatorScope;
import org.apache.calcite.sql.validate.implicit.TypeCoercion;
import org.apache.calcite.sql.validate.implicit.TypeCoercionImpl;
import org.checkerframework.checker.nullness.qual.Nullable;
@@ -77,6 +79,23 @@ public class DruidTypeCoercion extends TypeCoercionImpl
return super.commonTypeForBinaryComparison(type1, type2);
}
+ /**
+ * Casts the operand at the given index of a call to the target type, if a
cast is needed. Exposes
+ * {@link #coerceOperandType(SqlValidatorScope, SqlCall, int, RelDataType)}
to operand type checkers, such as
+ * {@link org.apache.druid.sql.calcite.expression.DefaultOperandTypeChecker}.
+ *
+ * @return true if the operand was cast
+ */
+ public boolean coerceOperand(
+ @Nullable SqlValidatorScope scope,
+ SqlCall call,
+ int index,
+ RelDataType targetType
+ )
+ {
+ return coerceOperandType(scope, call, index, targetType);
+ }
+
/**
* Choose a common array element type for comparisons.
*/
diff --git
a/sql/src/test/java/org/apache/druid/sql/calcite/CalciteQueryTest.java
b/sql/src/test/java/org/apache/druid/sql/calcite/CalciteQueryTest.java
index 499df1dbddd..6bb807024ef 100644
--- a/sql/src/test/java/org/apache/druid/sql/calcite/CalciteQueryTest.java
+++ b/sql/src/test/java/org/apache/druid/sql/calcite/CalciteQueryTest.java
@@ -15403,6 +15403,27 @@ public class CalciteQueryTest extends
BaseCalciteQueryTest
}
}
+ @Test
+ public void testCountDistinctNonNumericStringLiteral()
+ {
+ // COUNT shares the argument translation of numeric aggregators, but must
still accept any string literal
+ testBuilder()
+ .sql("SELECT COUNT(DISTINCT 'abc') FROM foo")
+ .expectedResults(ImmutableList.of(new Object[]{1L}))
+ .run();
+ }
+
+ @Test
+ public void testScalarSubqueryNonNumericStringLiteral()
+ {
+ // SINGLE_VALUE shares the argument translation of numeric aggregators,
but must still accept any string literal
+ cannotVectorize();
+ testBuilder()
+ .sql("SELECT COUNT(*) FROM foo WHERE dim1 = (SELECT 'abc' FROM foo
WHERE dim1 = 'def')")
+ .expectedResults(ImmutableList.of(new Object[]{1L}))
+ .run();
+ }
+
/**
* see {@link TestDataBuilder#RAW_ROWS1_WITH_NUMERIC_DIMS}
* for the input data source of this test
diff --git
a/sql/src/test/java/org/apache/druid/sql/calcite/aggregation/AggregationsTest.java
b/sql/src/test/java/org/apache/druid/sql/calcite/aggregation/AggregationsTest.java
new file mode 100644
index 00000000000..785dd7731c7
--- /dev/null
+++
b/sql/src/test/java/org/apache/druid/sql/calcite/aggregation/AggregationsTest.java
@@ -0,0 +1,118 @@
+/*
+ * 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.sql.calcite.aggregation;
+
+import org.apache.calcite.jdbc.JavaTypeFactoryImpl;
+import org.apache.calcite.rel.type.RelDataType;
+import org.apache.calcite.rex.RexBuilder;
+import org.apache.calcite.rex.RexNode;
+import org.apache.calcite.sql.type.SqlTypeName;
+import org.apache.druid.error.DruidException;
+import org.apache.druid.segment.column.ColumnType;
+import org.apache.druid.segment.column.RowSignature;
+import org.apache.druid.sql.calcite.expression.DruidExpression;
+import org.apache.druid.sql.calcite.expression.ExpressionTestHelper;
+import org.apache.druid.sql.calcite.table.RowSignatures;
+import org.apache.druid.sql.calcite.util.CalciteTestBase;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+public class AggregationsTest extends CalciteTestBase
+{
+ private static final RowSignature ROW_SIGNATURE = RowSignature.builder()
+ .add("s",
ColumnType.STRING)
+ .add("d",
ColumnType.DOUBLE)
+ .build();
+
+ private final JavaTypeFactoryImpl typeFactory = new JavaTypeFactoryImpl();
+ private final RexBuilder rexBuilder = new RexBuilder(typeFactory);
+
+ @Test
+ public void testNumericAggregatorWithNonNumericStringLiteral()
+ {
+ for (String value : new String[]{"m1", "abc", "", " 1 "}) {
+ final DruidException e = Assertions.assertThrows(
+ DruidException.class,
+ () -> toDruidExpression(rexBuilder.makeLiteral(value))
+ );
+ Assertions.assertEquals(DruidException.Persona.USER,
e.getTargetPersona());
+ Assertions.assertEquals(DruidException.Category.INVALID_INPUT,
e.getCategory());
+ Assertions.assertEquals(
+ "Aggregator input must be numeric, got string literal ['" + value +
"']."
+ + " If you meant to refer to a column, use double quotes.",
+ e.getMessage()
+ );
+ }
+ }
+
+ @Test
+ public void testNumericAggregatorWithNumericStringLiteral()
+ {
+ // Strings that are implicitly converted to numbers at runtime, see
ExprEval.computeNumber
+ for (String value : new String[]{"1", "-1.5", "1e3", "true", "false"}) {
+ final DruidExpression expression =
toDruidExpression(rexBuilder.makeLiteral(value));
+ Assertions.assertNotNull(expression, value);
+ Assertions.assertFalse(expression.isDirectColumnAccess(), value);
+ }
+ }
+
+ @Test
+ public void testNumericAggregatorWithNullLiteral()
+ {
+
Assertions.assertNotNull(toDruidExpression(rexBuilder.makeNullLiteral(typeFactory.createSqlType(SqlTypeName.VARCHAR))));
+
Assertions.assertNotNull(toDruidExpression(rexBuilder.makeNullLiteral(typeFactory.createSqlType(SqlTypeName.DOUBLE))));
+
Assertions.assertNotNull(toDruidExpression(rexBuilder.makeNullLiteral(typeFactory.createSqlType(SqlTypeName.NULL))));
+ }
+
+ @Test
+ public void testNumericAggregatorWithStringColumn()
+ {
+ // STRING columns are implicitly converted, so they are used through an
expression instead of a direct access
+ final DruidExpression expression = toDruidExpression(makeInputRef("s"));
+ Assertions.assertNotNull(expression);
+ Assertions.assertFalse(expression.isDirectColumnAccess());
+ Assertions.assertEquals("\"s\"", expression.getExpression());
+ }
+
+ @Test
+ public void testNumericAggregatorWithNumericColumn()
+ {
+ final DruidExpression expression = toDruidExpression(makeInputRef("d"));
+ Assertions.assertNotNull(expression);
+ Assertions.assertTrue(expression.isDirectColumnAccess());
+ Assertions.assertEquals("d", expression.getDirectColumn());
+ }
+
+ private DruidExpression toDruidExpression(final RexNode rexNode)
+ {
+ return Aggregations.toDruidExpressionForNumericAggregator(
+ ExpressionTestHelper.PLANNER_CONTEXT,
+ ROW_SIGNATURE,
+ rexNode
+ );
+ }
+
+ private RexNode makeInputRef(final String columnName)
+ {
+ final RelDataType rowType = RowSignatures.toRelDataType(ROW_SIGNATURE,
typeFactory);
+ final int index = ROW_SIGNATURE.indexOf(columnName);
+ return
rexBuilder.makeInputRef(rowType.getFieldList().get(index).getType(), index);
+ }
+}
diff --git
a/sql/src/test/java/org/apache/druid/sql/calcite/expression/OperandTypeTest.java
b/sql/src/test/java/org/apache/druid/sql/calcite/expression/OperandTypeTest.java
new file mode 100644
index 00000000000..3d2540a3742
--- /dev/null
+++
b/sql/src/test/java/org/apache/druid/sql/calcite/expression/OperandTypeTest.java
@@ -0,0 +1,114 @@
+/*
+ * 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.sql.calcite.expression;
+
+import org.apache.calcite.sql.type.SqlTypeFactoryImpl;
+import org.apache.calcite.sql.type.SqlTypeFamily;
+import org.apache.calcite.sql.type.SqlTypeName;
+import org.apache.druid.error.DruidException;
+import org.apache.druid.segment.column.ColumnType;
+import org.apache.druid.sql.calcite.planner.DruidTypeSystem;
+import org.apache.druid.sql.calcite.table.RowSignatures;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+public class OperandTypeTest
+{
+ private static final SqlTypeFactoryImpl TYPE_FACTORY = new
SqlTypeFactoryImpl(DruidTypeSystem.INSTANCE);
+ private static final ColumnType COMPLEX_TYPE =
ColumnType.ofComplex("someComplex");
+
+ @Test
+ public void testOf()
+ {
+ final OperandType type = OperandType.of(SqlTypeFamily.STRING);
+ Assertions.assertEquals(SqlTypeFamily.STRING, type.getFamily());
+ Assertions.assertFalse(type.isCoerceStrings());
+ Assertions.assertFalse(type.isNullable());
+
Assertions.assertFalse(type.isAllowedComplexType(RowSignatures.makeComplexType(TYPE_FACTORY,
COMPLEX_TYPE, false)));
+ Assertions.assertEquals("STRING", type.toString());
+ }
+
+ @Test
+ public void testNullable()
+ {
+ final OperandType numeric =
OperandType.numeric().orComplex(COMPLEX_TYPE).coerceStrings();
+ final OperandType type = numeric.nullable();
+
+ Assertions.assertTrue(type.isNullable());
+ Assertions.assertFalse(numeric.isNullable());
+ Assertions.assertTrue(type.isCoerceStrings());
+
Assertions.assertTrue(type.isAllowedComplexType(RowSignatures.makeComplexType(TYPE_FACTORY,
COMPLEX_TYPE, false)));
+
+ // nullable() is kept by the other methods.
+
Assertions.assertTrue(OperandType.numeric().nullable().orComplex(COMPLEX_TYPE).coerceStrings().isNullable());
+ }
+
+ @Test
+ public void testOrComplex()
+ {
+ final OperandType numeric = OperandType.numeric();
+ final OperandType type = numeric.orComplex(COMPLEX_TYPE);
+
+ Assertions.assertEquals(SqlTypeFamily.NUMERIC, type.getFamily());
+
Assertions.assertTrue(type.isAllowedComplexType(RowSignatures.makeComplexType(TYPE_FACTORY,
COMPLEX_TYPE, false)));
+
Assertions.assertTrue(type.isAllowedComplexType(RowSignatures.makeComplexType(TYPE_FACTORY,
COMPLEX_TYPE, true)));
+ Assertions.assertFalse(
+ type.isAllowedComplexType(RowSignatures.makeComplexType(TYPE_FACTORY,
ColumnType.ofComplex("other"), false))
+ );
+
Assertions.assertFalse(type.isAllowedComplexType(TYPE_FACTORY.createSqlType(SqlTypeName.DOUBLE)));
+ Assertions.assertEquals("NUMERIC | COMPLEX<someComplex>", type.toString());
+
+ // The original operand type is not changed.
+ Assertions.assertFalse(
+
numeric.isAllowedComplexType(RowSignatures.makeComplexType(TYPE_FACTORY,
COMPLEX_TYPE, false))
+ );
+ }
+
+ @Test
+ public void testOrComplexOnAnyOperand()
+ {
+ final DruidException e = Assertions.assertThrows(
+ DruidException.class,
+ () -> OperandType.of(SqlTypeFamily.ANY).orComplex(COMPLEX_TYPE)
+ );
+ Assertions.assertEquals("orComplex() does not apply to [ANY] operands",
e.getMessage());
+ }
+
+ @Test
+ public void testCoerceStrings()
+ {
+ final OperandType numeric = OperandType.numeric().orComplex(COMPLEX_TYPE);
+ final OperandType type = numeric.coerceStrings();
+
+ Assertions.assertTrue(type.isCoerceStrings());
+ Assertions.assertFalse(numeric.isCoerceStrings());
+
Assertions.assertTrue(type.isAllowedComplexType(RowSignatures.makeComplexType(TYPE_FACTORY,
COMPLEX_TYPE, false)));
+ }
+
+ @Test
+ public void testCoerceStringsOnNonNumericOperand()
+ {
+ final DruidException e = Assertions.assertThrows(
+ DruidException.class,
+ () -> OperandType.of(SqlTypeFamily.STRING).coerceStrings()
+ );
+ Assertions.assertEquals("coerceStrings() only applies to [NUMERIC]
operands, got [STRING]", e.getMessage());
+ }
+}
diff --git
a/sql/src/test/java/org/apache/druid/sql/calcite/expression/OperatorConversionsTest.java
b/sql/src/test/java/org/apache/druid/sql/calcite/expression/OperatorConversionsTest.java
index 486e48349b8..1f09272f3e9 100644
---
a/sql/src/test/java/org/apache/druid/sql/calcite/expression/OperatorConversionsTest.java
+++
b/sql/src/test/java/org/apache/druid/sql/calcite/expression/OperatorConversionsTest.java
@@ -20,6 +20,7 @@
package org.apache.druid.sql.calcite.expression;
import com.google.common.collect.ImmutableList;
+import it.unimi.dsi.fastutil.ints.IntArraySet;
import org.apache.calcite.rel.type.RelDataType;
import org.apache.calcite.runtime.CalciteContextException;
import org.apache.calcite.runtime.Resources.ExInst;
@@ -36,8 +37,13 @@ import org.apache.calcite.sql.type.SqlTypeFamily;
import org.apache.calcite.sql.type.SqlTypeName;
import org.apache.calcite.sql.validate.SqlValidator;
import org.apache.calcite.sql.validate.SqlValidatorScope;
+import org.apache.calcite.sql.validate.implicit.TypeCoercion;
+import org.apache.druid.error.DruidException;
import org.apache.druid.java.util.common.StringUtils;
+import org.apache.druid.segment.column.ColumnType;
+import org.apache.druid.sql.calcite.planner.DruidTypeCoercion;
import org.apache.druid.sql.calcite.planner.DruidTypeSystem;
+import org.apache.druid.sql.calcite.table.RowSignatures;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
@@ -45,11 +51,16 @@ import org.mockito.ArgumentMatchers;
import org.mockito.Mockito;
import org.mockito.stubbing.Answer;
+import javax.annotation.Nullable;
import java.util.ArrayList;
import java.util.List;
public class OperatorConversionsTest
{
+ private static final SqlTypeFactoryImpl TYPE_FACTORY = new
SqlTypeFactoryImpl(DruidTypeSystem.INSTANCE);
+ private static final ColumnType ALLOWED_COMPLEX_TYPE =
ColumnType.ofComplex("allowedComplex");
+ private static final ColumnType OTHER_COMPLEX_TYPE =
ColumnType.ofComplex("otherComplex");
+
@Nested
public class DefaultOperandTypeCheckerTest
{
@@ -384,6 +395,64 @@ public class OperatorConversionsTest
);
}
+ @Test
+ public void testBuildNullableOperands()
+ {
+ final List<OperandType> operandTypes = ImmutableList.of(
+ OperandType.of(SqlTypeFamily.CHARACTER),
+ OperandType.of(SqlTypeFamily.CHARACTER).nullable(),
+ OperandType.of(SqlTypeFamily.CHARACTER),
+ OperandType.of(SqlTypeFamily.CHARACTER)
+ );
+ Assertions.assertEquals(
+ new IntArraySet(new int[]{1, 3}),
+ DefaultOperandTypeChecker.buildNullableOperands(3, operandTypes, new
IntArraySet())
+ );
+ Assertions.assertEquals(
+ new IntArraySet(new int[]{1, 2, 3}),
+ DefaultOperandTypeChecker.buildNullableOperands(2, operandTypes, new
IntArraySet())
+ );
+ Assertions.assertTrue(
+ DefaultOperandTypeChecker.buildNullableOperands(3, operandTypes, new
IntArraySet(new int[]{1, 3})).isEmpty()
+ );
+ }
+
+ @Test
+ public void testNullLiteralForNullableRequiredOperand()
+ {
+ SqlFunction function = OperatorConversions
+ .operatorBuilder("testNullLiteralForNullableRequiredOperand")
+ .operandTypes(OperandType.of(SqlTypeFamily.CHARACTER).nullable(),
OperandType.of(SqlTypeFamily.CHARACTER))
+ .returnTypeNonNull(SqlTypeName.CHAR)
+ .build();
+ SqlOperandTypeChecker typeChecker = function.getOperandTypeChecker();
+ Assertions.assertTrue(
+ typeChecker.checkOperandTypes(
+ mockCallBinding(
+ function,
+ ImmutableList.of(
+ new OperandSpec(SqlTypeName.NULL, true),
+ new OperandSpec(SqlTypeName.CHAR, false)
+ )
+ ),
+ true
+ )
+ );
+ assertThrowsWithMessage(
+ () -> typeChecker.checkOperandTypes(
+ mockCallBinding(
+ function,
+ ImmutableList.of(
+ new OperandSpec(SqlTypeName.CHAR, false),
+ new OperandSpec(SqlTypeName.NULL, true)
+ )
+ ),
+ true
+ ),
+ "Exception in test for
operator[testNullLiteralForNullableRequiredOperand]: Illegal use of 'NULL'"
+ );
+ }
+
@Test
public void testNonCastableType()
{
@@ -409,6 +478,203 @@ public class OperatorConversionsTest
);
}
+ @Test
+ public void testAllowedComplexTypeOperand()
+ {
+ SqlFunction function =
numericOperandsFunction("testAllowedComplexTypeOperand");
+ SqlOperandTypeChecker typeChecker = function.getOperandTypeChecker();
+ Assertions.assertTrue(
+ typeChecker.checkOperandTypes(
+ mockCallBinding(
+ function,
+ ImmutableList.of(
+ new
OperandSpec(RowSignatures.makeComplexType(TYPE_FACTORY, ALLOWED_COMPLEX_TYPE,
false)),
+ new OperandSpec(SqlTypeName.DOUBLE, false)
+ )
+ ),
+ true
+ )
+ );
+ }
+
+ @Test
+ public void testOtherComplexTypeOperand()
+ {
+ SqlFunction function =
numericOperandsFunction("testOtherComplexTypeOperand");
+ SqlOperandTypeChecker typeChecker = function.getOperandTypeChecker();
+ assertThrowsWithMessage(
+ () -> typeChecker.checkOperandTypes(
+ mockCallBinding(
+ function,
+ ImmutableList.of(
+ new
OperandSpec(RowSignatures.makeComplexType(TYPE_FACTORY, OTHER_COMPLEX_TYPE,
true)),
+ new OperandSpec(SqlTypeName.DOUBLE, false)
+ )
+ ),
+ true
+ ),
+ "Cannot apply 'testOtherComplexTypeOperand' to arguments of type"
+ );
+ }
+
+ @Test
+ public void testOperandWithoutOptions()
+ {
+ // Only the first operand allows complex types and string coercion, so
the second one is checked as usual
+ SqlFunction function =
numericOperandsFunction("testOperandWithoutOptions");
+ SqlOperandTypeChecker typeChecker = function.getOperandTypeChecker();
+ final List<OperandSpec> invalidSecondOperands = ImmutableList.of(
+ new OperandSpec(RowSignatures.makeComplexType(TYPE_FACTORY,
ALLOWED_COMPLEX_TYPE, true)),
+ new OperandSpec(SqlTypeName.VARCHAR, false),
+ new OperandSpec(SqlLiteral.createCharString("abc",
SqlParserPos.ZERO))
+ );
+ for (OperandSpec secondOperand : invalidSecondOperands) {
+ final TypeCoercion typeCoercion =
Mockito.mock(DruidTypeCoercion.class);
+ assertThrowsWithMessage(
+ () -> typeChecker.checkOperandTypes(
+ mockCallBinding(
+ function,
+ ImmutableList.of(new OperandSpec(SqlTypeName.DOUBLE,
false), secondOperand),
+ typeCoercion
+ ),
+ true
+ ),
+ "Cannot apply 'testOperandWithoutOptions' to arguments of type"
+ );
+ Mockito.verifyNoInteractions(typeCoercion);
+ }
+ }
+
+ @Test
+ public void testNonNumericStringLiteralOnCoercibleOperand()
+ {
+ SqlFunction function =
numericOperandsFunction("testNonNumericStringLiteralOnCoercibleOperand");
+ SqlOperandTypeChecker typeChecker = function.getOperandTypeChecker();
+ final TypeCoercion typeCoercion = Mockito.mock(DruidTypeCoercion.class);
+ final List<OperandSpec> operands = ImmutableList.of(
+ new OperandSpec(SqlLiteral.createCharString("m1",
SqlParserPos.ZERO)),
+ new OperandSpec(SqlTypeName.DOUBLE, false)
+ );
+
+ final DruidException e = Assertions.assertThrows(
+ DruidException.class,
+ () -> typeChecker.checkOperandTypes(mockCallBinding(function,
operands, typeCoercion), true)
+ );
+ Assertions.assertEquals(DruidException.Persona.USER,
e.getTargetPersona());
+ Assertions.assertEquals(DruidException.Category.INVALID_INPUT,
e.getCategory());
+ Assertions.assertEquals(
+ "Function [testNonNumericStringLiteralOnCoercibleOperand] argument
[x] must be numeric, got string literal"
+ + " ['m1']. If you meant to refer to a column, use double quotes.",
+ e.getMessage()
+ );
+
+
Assertions.assertFalse(typeChecker.checkOperandTypes(mockCallBinding(function,
operands, typeCoercion), false));
+ Mockito.verifyNoInteractions(typeCoercion);
+ }
+
+ @Test
+ public void testNumericStringLiteralOnCoercibleOperand()
+ {
+ assertCoercedToDouble(
+ "testNumericStringLiteralOnCoercibleOperand",
+ new OperandSpec(SqlLiteral.createCharString("1.5",
SqlParserPos.ZERO))
+ );
+ }
+
+ @Test
+ public void testStringOperandOnCoercibleOperand()
+ {
+ assertCoercedToDouble("testStringOperandOnCoercibleOperand", new
OperandSpec(SqlTypeName.VARCHAR, false, true));
+ }
+
+ @Test
+ public void testBooleanOperandOnCoercibleOperand()
+ {
+ SqlFunction function =
numericOperandsFunction("testBooleanOperandOnCoercibleOperand");
+ SqlOperandTypeChecker typeChecker = function.getOperandTypeChecker();
+ final TypeCoercion typeCoercion = Mockito.mock(DruidTypeCoercion.class);
+ assertThrowsWithMessage(
+ () -> typeChecker.checkOperandTypes(
+ mockCallBinding(
+ function,
+ ImmutableList.of(new OperandSpec(SqlTypeName.BOOLEAN,
false), new OperandSpec(SqlTypeName.DOUBLE, false)),
+ typeCoercion
+ ),
+ true
+ ),
+ "Cannot apply 'testBooleanOperandOnCoercibleOperand' to arguments of
type"
+ );
+ Mockito.verifyNoInteractions(typeCoercion);
+ }
+
+ @Test
+ public void testTimestampOperandOnCoercibleOperand()
+ {
+ SqlFunction function =
numericOperandsFunction("testTimestampOperandOnCoercibleOperand");
+ SqlOperandTypeChecker typeChecker = function.getOperandTypeChecker();
+ final TypeCoercion typeCoercion = Mockito.mock(DruidTypeCoercion.class);
+ Assertions.assertTrue(
+ typeChecker.checkOperandTypes(
+ mockCallBinding(
+ function,
+ ImmutableList.of(
+ new
OperandSpec(TYPE_FACTORY.createSqlType(SqlTypeName.TIMESTAMP)),
+ new OperandSpec(SqlTypeName.DOUBLE, false)
+ ),
+ typeCoercion
+ ),
+ true
+ )
+ );
+ Mockito.verifyNoInteractions(typeCoercion);
+ }
+
+ private void assertCoercedToDouble(String functionName, OperandSpec
firstOperand)
+ {
+ SqlFunction function = numericOperandsFunction(functionName);
+ SqlOperandTypeChecker typeChecker = function.getOperandTypeChecker();
+ final DruidTypeCoercion typeCoercion =
Mockito.mock(DruidTypeCoercion.class);
+ Mockito.when(
+ typeCoercion.coerceOperand(
+ ArgumentMatchers.any(),
+ ArgumentMatchers.any(),
+ ArgumentMatchers.anyInt(),
+ ArgumentMatchers.any()
+ )
+ ).thenReturn(true);
+ final SqlCallBinding callBinding = mockCallBinding(
+ function,
+ ImmutableList.of(firstOperand, new OperandSpec(SqlTypeName.DOUBLE,
false)),
+ typeCoercion
+ );
+
+ Assertions.assertTrue(typeChecker.checkOperandTypes(callBinding, true));
+ Mockito.verify(typeCoercion).coerceOperand(
+ ArgumentMatchers.eq(callBinding.getScope()),
+ ArgumentMatchers.same(callBinding.getCall()),
+ ArgumentMatchers.eq(0),
+ ArgumentMatchers.argThat(type -> type.getSqlTypeName() ==
SqlTypeName.DOUBLE)
+ );
+ Mockito.verifyNoMoreInteractions(typeCoercion);
+ }
+
+ /**
+ * Function with two numeric operands, where only the first one allows
{@link #ALLOWED_COMPLEX_TYPE} and coercion
+ * of strings.
+ */
+ private SqlFunction numericOperandsFunction(String name)
+ {
+ return OperatorConversions
+ .operatorBuilder(name)
+ .operandNames("x", "y")
+ .operandTypes(
+
OperandType.numeric().orComplex(ALLOWED_COMPLEX_TYPE).coerceStrings(),
+ OperandType.of(SqlTypeFamily.NUMERIC)
+ )
+ .returnTypeNonNull(SqlTypeName.DOUBLE)
+ .build();
+ }
+
private static void assertThrowsWithMessage(Runnable runnable, String
expectedMessage)
{
final CalciteContextException e =
Assertions.assertThrows(CalciteContextException.class, runnable::run);
@@ -450,25 +716,62 @@ public class OperatorConversionsTest
);
}
+ @Test
+ public void testSignatureWithoutNamesWithComplexTypes()
+ {
+ SqlFunction function = OperatorConversions
+ .operatorBuilder("testSignatureWithoutNamesWithComplexTypes")
+ .operandTypes(
+
OperandType.numeric().orComplex(ALLOWED_COMPLEX_TYPE).coerceStrings(),
+ OperandType.of(SqlTypeFamily.NUMERIC)
+ )
+ .returnTypeNonNull(SqlTypeName.DOUBLE)
+ .build();
+ SqlOperandTypeChecker typeChecker = function.getOperandTypeChecker();
+
+ Assertions.assertEquals(
+ "'testSignatureWithoutNamesWithComplexTypes(<NUMERIC |
COMPLEX<allowedComplex>>, <NUMERIC>)'",
+ typeChecker.getAllowedSignatures(function, function.getName())
+ );
+ }
+
private static SqlCallBinding mockCallBinding(
SqlFunction function,
List<OperandSpec> actualOperands
)
+ {
+ return mockCallBinding(function, actualOperands, null);
+ }
+
+ private static SqlCallBinding mockCallBinding(
+ SqlFunction function,
+ List<OperandSpec> actualOperands,
+ @Nullable TypeCoercion typeCoercion
+ )
{
SqlValidator validator = Mockito.mock(SqlValidator.class);
- Mockito.when(validator.getTypeFactory()).thenReturn(new
SqlTypeFactoryImpl(DruidTypeSystem.INSTANCE));
+ Mockito.when(validator.getTypeFactory()).thenReturn(TYPE_FACTORY);
+ Mockito.when(validator.config()).thenReturn(SqlValidator.Config.DEFAULT);
+ Mockito.when(validator.getTypeCoercion()).thenReturn(typeCoercion);
List<SqlNode> operands = new ArrayList<>(actualOperands.size());
for (OperandSpec operand : actualOperands) {
+ if (operand.node != null || operand.relDataType != null) {
+ final SqlNode node = operand.node != null ? operand.node :
nonLiteralNode();
+ final RelDataType relDataType = operand.relDataType != null
+ ? operand.relDataType
+ : ((SqlLiteral)
node).createSqlType(TYPE_FACTORY);
+ Mockito.when(validator.deriveType(ArgumentMatchers.any(),
ArgumentMatchers.same(node)))
+ .thenReturn(relDataType);
+ operands.add(node);
+ continue;
+ }
+
final SqlNode node;
if (operand.isLiteral) {
node = Mockito.mock(SqlLiteral.class);
Mockito.when(node.getKind()).thenReturn(SqlKind.LITERAL);
} else {
- node = Mockito.mock(SqlNode.class);
- // Setting this as SqlUtil.isLiteral makes a call on
- // node.getKind() and without this change would
- // return a NPE
- Mockito.when(node.getKind()).thenReturn(SqlKind.OTHER_FUNCTION);
+ node = nonLiteralNode();
}
RelDataType relDataType = Mockito.mock(RelDataType.class);
@@ -502,12 +805,34 @@ public class OperatorConversionsTest
return callBinding;
}
+ private static SqlNode nonLiteralNode()
+ {
+ final SqlNode node = Mockito.mock(SqlNode.class);
+ // Setting this as SqlUtil.isLiteral makes a call on
+ // node.getKind() and without this change would
+ // return a NPE
+ Mockito.when(node.getKind()).thenReturn(SqlKind.OTHER_FUNCTION);
+ return node;
+ }
+
private static class OperandSpec
{
private final SqlTypeName type;
private final boolean isLiteral;
private final boolean isNullable;
+ /**
+ * Actual node of the operand, used instead of a mock if not null.
+ */
+ @Nullable
+ private final SqlNode node;
+
+ /**
+ * Actual derived type of the operand, used instead of a mock if not
null.
+ */
+ @Nullable
+ private final RelDataType relDataType;
+
private OperandSpec(SqlTypeName type, boolean isLiteral)
{
this(type, isLiteral, type == SqlTypeName.NULL);
@@ -518,6 +843,26 @@ public class OperatorConversionsTest
this.type = type;
this.isLiteral = isLiteral;
this.isNullable = isNullable;
+ this.node = null;
+ this.relDataType = null;
+ }
+
+ private OperandSpec(SqlLiteral node)
+ {
+ this.type = null;
+ this.isLiteral = true;
+ this.isNullable = false;
+ this.node = node;
+ this.relDataType = null;
+ }
+
+ private OperandSpec(RelDataType relDataType)
+ {
+ this.type = relDataType.getSqlTypeName();
+ this.isLiteral = false;
+ this.isNullable = relDataType.isNullable();
+ this.node = null;
+ this.relDataType = relDataType;
}
}
}
diff --git
a/sql/src/test/java/org/apache/druid/sql/calcite/util/testoperator/AssertionErrorOperatorConversion.java
b/sql/src/test/java/org/apache/druid/sql/calcite/util/testoperator/AssertionErrorOperatorConversion.java
index 6d4aa44e5e7..116e965e79e 100644
---
a/sql/src/test/java/org/apache/druid/sql/calcite/util/testoperator/AssertionErrorOperatorConversion.java
+++
b/sql/src/test/java/org/apache/druid/sql/calcite/util/testoperator/AssertionErrorOperatorConversion.java
@@ -22,6 +22,7 @@ package org.apache.druid.sql.calcite.util.testoperator;
import org.apache.calcite.rex.RexLiteral;
import org.apache.calcite.rex.RexNode;
import org.apache.calcite.sql.SqlOperator;
+import org.apache.calcite.sql.type.SqlTypeFamily;
import org.apache.calcite.sql.type.SqlTypeName;
import org.apache.druid.segment.column.RowSignature;
import org.apache.druid.sql.calcite.expression.DruidExpression;
@@ -45,7 +46,7 @@ public class AssertionErrorOperatorConversion implements
SqlOperatorConversion
{
private static final SqlOperator OPERATOR =
OperatorConversions.operatorBuilder("assertion_error")
- .operandTypes()
+ .operandTypes(new SqlTypeFamily[0])
.returnTypeNonNull(SqlTypeName.BIGINT)
.build();
diff --git
a/sql/src/test/quidem/org.apache.druid.quidem.SqlQuidemTest/numeric_agg_string_args.iq
b/sql/src/test/quidem/org.apache.druid.quidem.SqlQuidemTest/numeric_agg_string_args.iq
new file mode 100644
index 00000000000..c6246a74565
--- /dev/null
+++
b/sql/src/test/quidem/org.apache.druid.quidem.SqlQuidemTest/numeric_agg_string_args.iq
@@ -0,0 +1,167 @@
+!use druidtest:///
+!set outputformat mysql
+
+# Numeric aggregators on STRING columns and string literals.
+# A STRING column is implicitly cast to a number for SUM, AVG and COUNT; MIN
and MAX reject it.
+
+# Case 1: SUM(dim1), STRING column.
+SELECT SUM(dim1) FROM foo;
++--------+
+| EXPR$0 |
++--------+
+| 13.1 |
++--------+
+(1 row)
+
+!ok
+
+# Case 2: MIN(dim1), STRING column.
+SELECT MIN(dim1) FROM foo;
+Aggregation [MIN] does not support type [STRING], column [v0]
+!error
+
+# Case 3: MAX(dim1), STRING column.
+SELECT MAX(dim1) FROM foo;
+Aggregation [MAX] does not support type [STRING], column [v0]
+!error
+
+# Case 4: AVG(dim1), STRING column.
+SELECT AVG(dim1) FROM foo;
++-------------------+
+| EXPR$0 |
++-------------------+
+| 4.366666666666666 |
++-------------------+
+(1 row)
+
+!ok
+
+# Case 5: COUNT(dim1), STRING column.
+SELECT COUNT(dim1) FROM foo;
++--------+
+| EXPR$0 |
++--------+
+| 6 |
++--------+
+(1 row)
+
+!ok
+
+# Case 6: COUNT(DISTINCT dim1), STRING column.
+SELECT COUNT(DISTINCT dim1) FROM foo;
++--------+
+| EXPR$0 |
++--------+
+| 6 |
++--------+
+(1 row)
+
+!ok
+
+# Case 7: SUM('1.5'), numeric string literal.
+SELECT SUM('1.5') FROM foo;
++--------+
+| EXPR$0 |
++--------+
+| 9.0 |
++--------+
+(1 row)
+
+!ok
+
+# Case 8: MIN('1.5'), numeric string literal.
+SELECT MIN('1.5') FROM foo;
+Aggregation [MIN] does not support type [STRING], column [v0]
+!error
+
+# Case 9: MAX('1.5'), numeric string literal.
+SELECT MAX('1.5') FROM foo;
+Aggregation [MAX] does not support type [STRING], column [v0]
+!error
+
+# Case 10: AVG('1.5'), numeric string literal.
+SELECT AVG('1.5') FROM foo;
++--------+
+| EXPR$0 |
++--------+
+| 1.5 |
++--------+
+(1 row)
+
+!ok
+
+# Case 11: COUNT('1.5'), numeric string literal.
+SELECT COUNT('1.5') FROM foo;
++--------+
+| EXPR$0 |
++--------+
+| 6 |
++--------+
+(1 row)
+
+!ok
+
+# Case 12: COUNT(DISTINCT '1.5'), numeric string literal.
+SELECT COUNT(DISTINCT '1.5') FROM foo;
++--------+
+| EXPR$0 |
++--------+
+| 1 |
++--------+
+(1 row)
+
+!ok
+
+# Case 13: SUM('abc'), non-numeric string literal.
+SELECT SUM('abc') FROM foo;
++--------+
+| EXPR$0 |
++--------+
+| |
++--------+
+(1 row)
+
+!ok
+
+# Case 14: MIN('abc'), non-numeric string literal.
+SELECT MIN('abc') FROM foo;
+Aggregation [MIN] does not support type [STRING], column [v0]
+!error
+
+# Case 15: MAX('abc'), non-numeric string literal.
+SELECT MAX('abc') FROM foo;
+Aggregation [MAX] does not support type [STRING], column [v0]
+!error
+
+# Case 16: AVG('abc'), non-numeric string literal.
+SELECT AVG('abc') FROM foo;
++--------+
+| EXPR$0 |
++--------+
+| |
++--------+
+(1 row)
+
+!ok
+
+# Case 17: COUNT('abc'), non-numeric string literal.
+SELECT COUNT('abc') FROM foo;
++--------+
+| EXPR$0 |
++--------+
+| 6 |
++--------+
+(1 row)
+
+!ok
+
+# Case 18: COUNT(DISTINCT 'abc'), non-numeric string literal.
+SELECT COUNT(DISTINCT 'abc') FROM foo;
++--------+
+| EXPR$0 |
++--------+
+| 1 |
++--------+
+(1 row)
+
+!ok
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]