This is an automated email from the ASF dual-hosted git repository.
gortiz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/pinot.git
The following commit(s) were added to refs/heads/master by this push:
new 6ac0a577da5 Reject QUALIFY in the single-stage engine instead of
silently ignoring it (#19553)
6ac0a577da5 is described below
commit 6ac0a577da57b45cdaecbaffe0ca256d5d5b04e1
Author: Gonzalo Ortiz Jaureguizar <[email protected]>
AuthorDate: Tue Sep 15 11:45:48 2026 +0200
Reject QUALIFY in the single-stage engine instead of silently ignoring it
(#19553)
---
.../apache/pinot/sql/parsers/CalciteSqlParser.java | 10 +++++
.../sql/parsers/parser/TableNameExtractor.java | 5 +++
.../pinot/sql/parsers/CalciteSqlParserTest.java | 37 ++++++++++++++++++
.../sql/parsers/parser/TableNameExtractorTest.java | 14 +++++++
.../resources/queries/WindowFunctionPlans.json | 15 ++++++++
.../test/resources/queries/WindowFunctions.json | 44 +++++++++++++++++++++-
6 files changed, 124 insertions(+), 1 deletion(-)
diff --git
a/pinot-common/src/main/java/org/apache/pinot/sql/parsers/CalciteSqlParser.java
b/pinot-common/src/main/java/org/apache/pinot/sql/parsers/CalciteSqlParser.java
index 5a060850385..d82c1a96922 100644
---
a/pinot-common/src/main/java/org/apache/pinot/sql/parsers/CalciteSqlParser.java
+++
b/pinot-common/src/main/java/org/apache/pinot/sql/parsers/CalciteSqlParser.java
@@ -625,6 +625,16 @@ public class CalciteSqlParser {
if (havingNode != null) {
pinotQuery.setHavingExpression(toExpression(havingNode));
}
+ // QUALIFY
+ // QUALIFY filters rows after the window functions of the SELECT list are
evaluated, the way HAVING filters them
+ // after aggregation. The single-stage engine has no window functions, and
PinotQuery has no field to carry the
+ // predicate, so it must be rejected rather than left unread: an unread
QUALIFY is a silently wrong answer.
+ if (selectNode.getQualify() != null) {
+ throw new SqlCompilationException("QUALIFY is not supported by the
single-stage query engine. Use the "
+ + "multi-stage query engine to filter on the result of a window
function. If the predicate does not "
+ + "reference a window function, rewrite it as a WHERE clause (to
filter on columns) or as a HAVING clause "
+ + "(to filter on aggregates of a GROUP BY query).");
+ }
// ORDER-BY
SqlNodeList orderByNodeList = selectNode.getOrderList();
if (orderByNodeList != null) {
diff --git
a/pinot-common/src/main/java/org/apache/pinot/sql/parsers/parser/TableNameExtractor.java
b/pinot-common/src/main/java/org/apache/pinot/sql/parsers/parser/TableNameExtractor.java
index 9b1b424f11c..edce1054533 100644
---
a/pinot-common/src/main/java/org/apache/pinot/sql/parsers/parser/TableNameExtractor.java
+++
b/pinot-common/src/main/java/org/apache/pinot/sql/parsers/parser/TableNameExtractor.java
@@ -167,6 +167,11 @@ public class TableNameExtractor {
if (select.getHaving() != null) {
extractTableNames(select.getHaving());
}
+ // QUALIFY is rejected by the single-stage engine but legal for the
multi-stage one, which is what this extractor
+ // runs on, so a subquery hiding in it still has to be walked.
+ if (select.getQualify() != null) {
+ extractTableNames(select.getQualify());
+ }
if (select.getOrderList() != null) {
visitNodeList(select.getOrderList());
}
diff --git
a/pinot-common/src/test/java/org/apache/pinot/sql/parsers/CalciteSqlParserTest.java
b/pinot-common/src/test/java/org/apache/pinot/sql/parsers/CalciteSqlParserTest.java
index 47d64c17a65..dec47000efb 100644
---
a/pinot-common/src/test/java/org/apache/pinot/sql/parsers/CalciteSqlParserTest.java
+++
b/pinot-common/src/test/java/org/apache/pinot/sql/parsers/CalciteSqlParserTest.java
@@ -30,6 +30,8 @@ import org.testng.annotations.Test;
import static
org.apache.pinot.sql.parsers.CalciteSqlParser.CALCITE_SQL_PARSER_IDENTIFIER_MAX_LENGTH;
import static org.testng.Assert.assertEquals;
import static org.testng.Assert.assertThrows;
+import static org.testng.Assert.assertTrue;
+import static org.testng.Assert.expectThrows;
/// Tests for CalciteSqlParser.
@@ -255,4 +257,39 @@ public class CalciteSqlParserTest {
Function function = filterExpr.getFunctionCall();
assertEquals(function.getOperands().get(1).getLiteral().getStringValue(),
"It's fine");
}
+
+ /// QUALIFY used to be parsed and then silently dropped, so a query relying
on it returned every row instead of the
+ /// filtered ones. The single-stage engine cannot evaluate it, so it must
fail loudly.
+ @Test
+ public void testQualifyIsRejected() {
+ // The idiom from the bug report: keep the latest row per partition.
+ assertQualifyRejected("SELECT city, category FROM myTable "
+ + "QUALIFY ROW_NUMBER() OVER (PARTITION BY city ORDER BY orderDate
DESC) = 1 ORDER BY 1, 2 LIMIT 100");
+ // The same idiom written against a SELECT-list alias, which is the more
common spelling. It reaches the check
+ // only because toExpression() compiles the SqlWindow operand to a literal
instead of throwing; assert on the
+ // QUALIFY message so tightening that branch cannot silently downgrade
this to "Unsupported sql node".
+ assertQualifyRejected("SELECT city, ROW_NUMBER() OVER (PARTITION BY city
ORDER BY orderDate DESC) AS rn "
+ + "FROM myTable QUALIFY rn = 1");
+ // Without a window function QUALIFY is still rejected: the single-stage
engine only applies HAVING to GROUP BY
+ // queries, so folding the predicate into HAVING would drop it just as
silently for the other query shapes.
+ // These shapes are not valid SQL either -- the multi-stage engine rejects
them with "QUALIFY expression must
+ // contain a window function" -- which is why the message conditions its
two remedies on whether the predicate
+ // references a window function rather than recommending either one
outright.
+ assertQualifyRejected("SELECT city FROM myTable QUALIFY city > 'a'");
+ assertQualifyRejected("SELECT city, COUNT(*) FROM myTable GROUP BY city
QUALIFY COUNT(*) > 5");
+ // A QUALIFY next to a HAVING must not be swallowed by the HAVING being
present.
+ assertQualifyRejected(
+ "SELECT city, COUNT(*) FROM myTable GROUP BY city HAVING COUNT(*) > 3
QUALIFY COUNT(*) > 5");
+ // Subqueries are compiled through the same path.
+ assertQualifyRejected("SELECT city FROM (SELECT city FROM myTable QUALIFY
city > 'a') AS t");
+ // EXPLAIN unwraps to the same SELECT node.
+ assertQualifyRejected("EXPLAIN PLAN FOR SELECT city FROM myTable QUALIFY
city > 'a'");
+ }
+
+ private void assertQualifyRejected(String query) {
+ SqlCompilationException e =
+ expectThrows(SqlCompilationException.class, () ->
CalciteSqlParser.compileToPinotQuery(query));
+ assertTrue(e.getMessage().contains("QUALIFY is not supported by the
single-stage query engine"),
+ "Unexpected message for query '" + query + "': " + e.getMessage());
+ }
}
diff --git
a/pinot-common/src/test/java/org/apache/pinot/sql/parsers/parser/TableNameExtractorTest.java
b/pinot-common/src/test/java/org/apache/pinot/sql/parsers/parser/TableNameExtractorTest.java
index 97dbce96d2a..4eaa3f8ab93 100644
---
a/pinot-common/src/test/java/org/apache/pinot/sql/parsers/parser/TableNameExtractorTest.java
+++
b/pinot-common/src/test/java/org/apache/pinot/sql/parsers/parser/TableNameExtractorTest.java
@@ -269,6 +269,20 @@ public class TableNameExtractorTest {
assertTrue(Arrays.asList(tableNames).contains("orders"), "Should contain
orders table");
}
+ @Test
+ public void testResolveTableNameWithQualifySubquery() {
+ // QUALIFY is legal for the multi-stage engine, which is what this
extractor runs on, so a table referenced only
+ // from inside it must still be found.
+ String qualifySubqueryQuery = "SELECT u.id, ROW_NUMBER() OVER (PARTITION
BY u.id) AS rn FROM users u "
+ + "QUALIFY rn <= (SELECT COUNT(*) FROM orders o WHERE o.user_id =
u.id)";
+ String[] tableNames =
TableNameExtractor.resolveTableName(qualifySubqueryQuery);
+
+ assertNotNull(tableNames, "Table names should not be null");
+ assertEquals(tableNames.length, 2, "Should resolve two tables");
+ assertTrue(Arrays.asList(tableNames).contains("users"), "Should contain
users table");
+ assertTrue(Arrays.asList(tableNames).contains("orders"), "Should contain
orders table from QUALIFY subquery");
+ }
+
@Test(expectedExceptions = RuntimeException.class)
public void testResolveTableNameWithInvalidQuery() {
String[] tableNames = TableNameExtractor.resolveTableName("INVALID SQL
QUERY");
diff --git
a/pinot-query-planner/src/test/resources/queries/WindowFunctionPlans.json
b/pinot-query-planner/src/test/resources/queries/WindowFunctionPlans.json
index cea44cc7f06..1fc70530dac 100644
--- a/pinot-query-planner/src/test/resources/queries/WindowFunctionPlans.json
+++ b/pinot-query-planner/src/test/resources/queries/WindowFunctionPlans.json
@@ -582,6 +582,21 @@
"\n"
]
},
+ {
+ "description": "QUALIFY desugars to a filter over the window, keeping
the top row per partition",
+ "notes": "QUALIFY is rejected by the single-stage engine (it has no
window functions), so this is the only engine that runs it",
+ "sql": "EXPLAIN PLAN FOR SELECT a.col1, a.col2 FROM a QUALIFY
ROW_NUMBER() OVER(PARTITION BY a.col2 ORDER BY a.col3 DESC) = 1",
+ "output": [
+ "Execution Plan",
+ "\nLogicalProject(col1=[$0], col2=[$1])",
+ "\n LogicalFilter(condition=[=($3, 1)])",
+ "\n LogicalWindow(window#0=[window(partition {1} order by [2
DESC] rows between UNBOUNDED PRECEDING and CURRENT ROW aggs [ROW_NUMBER()])])",
+ "\n PinotLogicalSortExchange(distribution=[hash[1]],
collation=[[2 DESC]], isSortOnSender=[false], isSortOnReceiver=[true])",
+ "\n LogicalProject(col1=[$0], col2=[$1], col3=[$2])",
+ "\n PinotLogicalTableScan(table=[[default, a]])",
+ "\n"
+ ]
+ },
{
"description": "single OVER(PARTITION BY) only with alias",
"sql": "EXPLAIN PLAN FOR SELECT SUM(a.col3) OVER(PARTITION BY a.col2)
AS sum FROM a",
diff --git
a/pinot-query-runtime/src/test/resources/queries/WindowFunctions.json
b/pinot-query-runtime/src/test/resources/queries/WindowFunctions.json
index 44015a693d8..868d44d9cc3 100644
--- a/pinot-query-runtime/src/test/resources/queries/WindowFunctions.json
+++ b/pinot-query-runtime/src/test/resources/queries/WindowFunctions.json
@@ -5853,4 +5853,46 @@
}
]
}
-}
\ No newline at end of file
+ ,
+ "qualify_window_function_filter": {
+ "tables": {
+ "tbl": {
+ "schema": [
+ {"name": "city", "type": "STRING"},
+ {"name": "category", "type": "STRING"},
+ {"name": "order_date", "type": "INT"}
+ ],
+ "inputs": [
+ ["Athens", "Office Supplies", 3],
+ ["Athens", "Technology", 1],
+ ["Madrid", "Technology", 5],
+ ["Madrid", "Office Supplies", 2],
+ ["Springfield", "Furniture", 7]
+ ]
+ }
+ },
+ "queries": [
+ {
+ "description": "QUALIFY keeps only the latest row per city",
+ "notes": "The single-stage engine rejects QUALIFY (it has no window
functions), so this is the only engine that runs it",
+ "sql": "SELECT city, category FROM {tbl} QUALIFY ROW_NUMBER()
OVER(PARTITION BY city ORDER BY order_date DESC) = 1 ORDER BY city",
+ "keepOutputRowOrder": true,
+ "outputs": [
+ ["Athens", "Office Supplies"],
+ ["Madrid", "Technology"],
+ ["Springfield", "Furniture"]
+ ]
+ },
+ {
+ "description": "QUALIFY on a SELECT-list window alias",
+ "sql": "SELECT city, ROW_NUMBER() OVER(PARTITION BY city ORDER BY
order_date DESC) AS rn FROM {tbl} QUALIFY rn = 1 ORDER BY city",
+ "keepOutputRowOrder": true,
+ "outputs": [
+ ["Athens", 1],
+ ["Madrid", 1],
+ ["Springfield", 1]
+ ]
+ }
+ ]
+ }
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]