alamb commented on code in PR #25865:
URL: https://github.com/apache/datafusion/pull/25865#discussion_r4221307941
##########
datafusion/optimizer/src/push_down_filter.rs:
##########
@@ -1975,21 +2044,449 @@ mod tests {
let plan = LogicalPlanBuilder::from(table_scan)
.window(vec![window])?
- // unlike with aggregations, single partition column "test.a +
test.b" is not available
- // to the plan, so we use multiple columns when filtering
+ // the single partition column "test.a + test.b" is not available
to the
+ // plan, so the predicate is written over the underlying columns
.filter(add(col("a"), col("b")).gt(lit(10i64)))?
.build()?;
+ assert_optimized_plan_equal!(
Review Comment:
Is there a reason we can't use .slt for these tests? The setup code for
buulding the dataframe is quite verbose compare to the same thing as SQL, and I
found it hard to map from the dataframe API expressions to the SQL level
expressions (e.g. `add(col(a), col(b))` in the setup code, but `a + b` in the
explain)
I am wondering if there is some way to reduce the size of this PR
##########
datafusion/sqllogictest/test_files/push_down_filter_regression.slt:
##########
@@ -723,3 +723,92 @@ query I
SELECT sum(c) FROM (SELECT random() < 0.5 AS k, count(*) AS c FROM
generate_series(1, 10000) GROUP BY random() < 0.5) WHERE k OR NOT k;
----
10000
+
+# Window filter pushdown over an expression PARTITION BY key.
+#
+# A predicate that reads only the partition keys is constant within a
partition,
+# so pushing it below the window drops whole partitions and leaves every
+# surviving row's window value alone. The unit tests pin where the filter
lands;
Review Comment:
> The unit tests pin where the filter lands;
This (and the stuff below it) I think is pretty obvious (aka these are
explain tests so changes to where the filter is will cause the filters not to
push down)
##########
datafusion/optimizer/src/push_down_filter.rs:
##########
@@ -1585,6 +1596,63 @@ fn with_filters(predicates: Vec<Expr>, plan:
LogicalPlan) -> LogicalPlan {
}
}
+/// Does `expr` read nothing beyond the given window partition keys?
+///
+/// A subtree that is exactly one of the keys counts as read in full, so a
+/// predicate on an *expression* key, say `NULLIF(c, '') IS NOT NULL` against
+/// `PARTITION BY NULLIF(c, '')`, qualifies even though the column it
ultimately
+/// reads (`c`) is not a key on its own. Such a predicate is constant within
each
+/// partition, so applying it below the window drops whole partitions and
leaves
+/// every surviving row's window value unchanged.
+///
+/// Matching is structural, which makes this conservative rather than wrong: a
+/// predicate written `b + a` does not match a key written `a + b`, and is
simply
+/// left above the window.
+///
+/// A node carrying a subquery counts as reading something else, whatever the
Review Comment:
I found this paragraph very hard to understand -- I don't think we need to
explain the intricate details of why subqueries can't be pushed down
##########
datafusion/optimizer/src/push_down_filter.rs:
##########
@@ -1585,6 +1596,63 @@ fn with_filters(predicates: Vec<Expr>, plan:
LogicalPlan) -> LogicalPlan {
}
}
+/// Does `expr` read nothing beyond the given window partition keys?
+///
+/// A subtree that is exactly one of the keys counts as read in full, so a
Review Comment:
I found "is exactly one of the keys counts as read in full" very hard to
understand
I think this is trying to explain that we can pass any expression down that
only refers to partition columns (as in is an expression of columns that only
appear in the expression)?
##########
datafusion/optimizer/src/push_down_filter.rs:
##########
@@ -1135,8 +1135,13 @@ impl OptimizerRule for PushDownFilter {
// multiple window functions, each with potentially different
partition keys.
// Therefore, we need to ensure that any potential partition
key returned is used in
// ALL window functions. Otherwise, filters cannot be pushed
by through that column.
- fn extract_partition_keys(func: &WindowFunction) ->
HashSet<Column> {
- expr_columns(&func.params.partition_by)
+ // Keyed by the partition *expression*, not by a name
synthesised
Review Comment:
Is it important to explain here what used to happen? This comment on the old
behavior seems like it will be irrelevant once the PR will merge (it would be
better as a comment on the PR I think, not in the code)
##########
datafusion/optimizer/src/push_down_filter.rs:
##########
@@ -1172,21 +1177,27 @@ impl OptimizerRule for PushDownFilter {
let mut keep_predicates = vec![];
let mut push_predicates = vec![];
for expr in predicates {
- let cols = expr.column_refs();
- if cols.iter().all(|c|
potential_partition_keys.contains(c)) {
+ // A volatile predicate has to stay above the window.
Pushing it
+ // changes which rows the window function sees, and so the
value
+ // it computes for the rows that do survive. Checking this
first
Review Comment:
I am not sure "Checking this first
// also covers a volatile predicate that reads no
columns at all,
// such as `random() < 0.5`, which would otherwise
satisfy the
// partition-key test vacuously."
is needed as it explain what the code does, doesn't it?
##########
datafusion/optimizer/src/push_down_filter.rs:
##########
@@ -1135,8 +1135,13 @@ impl OptimizerRule for PushDownFilter {
// multiple window functions, each with potentially different
partition keys.
// Therefore, we need to ensure that any potential partition
key returned is used in
// ALL window functions. Otherwise, filters cannot be pushed
by through that column.
- fn extract_partition_keys(func: &WindowFunction) ->
HashSet<Column> {
- expr_columns(&func.params.partition_by)
+ // Keyed by the partition *expression*, not by a name
synthesised
+ // from it. `PARTITION BY a + b` used to be mapped through
+ // `qualified_name()` to a column literally called "a + b",
which no
+ // predicate's real column refs could ever match, so such a
key was
+ // dead weight in this set.
+ fn extract_partition_keys(func: &WindowFunction) ->
HashSet<Expr> {
+ func.params.partition_by.iter().cloned().collect()
Review Comment:
do you need to clone here? Can we just use `HashSet<&Expr>` to keep
references rather tahn deeply clone the exprs?
##########
datafusion/sqllogictest/test_files/push_down_filter_regression.slt:
##########
@@ -723,3 +723,92 @@ query I
SELECT sum(c) FROM (SELECT random() < 0.5 AS k, count(*) AS c FROM
generate_series(1, 10000) GROUP BY random() < 0.5) WHERE k OR NOT k;
----
10000
+
+# Window filter pushdown over an expression PARTITION BY key.
+#
+# A predicate that reads only the partition keys is constant within a
partition,
+# so pushing it below the window drops whole partitions and leaves every
+# surviving row's window value alone. The unit tests pin where the filter
lands;
+# these pin that the answers do not move, which is what a wrong push breaks.
+
+statement ok
+create table window_expr_key(k varchar, v int) as values
+ ('a', 1), ('a', 2), ('a', 3),
+ ('', 4), ('', 5),
+ ('b', 6);
+
+# Partitions under NULLIF(k, '') are 'a' => {1,2,3}, NULL => {4,5}, 'b' => {6}.
Review Comment:
can you also please add EXPLAIN to these tests so we can see the shape?
##########
datafusion/optimizer/src/push_down_filter.rs:
##########
@@ -1585,6 +1596,63 @@ fn with_filters(predicates: Vec<Expr>, plan:
LogicalPlan) -> LogicalPlan {
}
}
+/// Does `expr` read nothing beyond the given window partition keys?
+///
+/// A subtree that is exactly one of the keys counts as read in full, so a
Review Comment:
I think this comment would be much more helpful with some examples
Something like
given `PARTITION BY (a, b)`
Can push down filters: `a < 5`, `a+b = 4` etc
Can not push down filters like `c < 5`
##########
datafusion/optimizer/src/push_down_filter.rs:
##########
@@ -1975,21 +2044,449 @@ mod tests {
let plan = LogicalPlanBuilder::from(table_scan)
.window(vec![window])?
- // unlike with aggregations, single partition column "test.a +
test.b" is not available
- // to the plan, so we use multiple columns when filtering
+ // the single partition column "test.a + test.b" is not available
to the
+ // plan, so the predicate is written over the underlying columns
.filter(add(col("a"), col("b")).gt(lit(10i64)))?
.build()?;
+ assert_optimized_plan_equal!(
+ plan,
+ @r"
+ WindowAggr: windowExpr=[[rank() PARTITION BY [test.a + test.b] ORDER
BY [test.c ASC NULLS FIRST] ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW]]
+ TableScan: test, full_filters=[test.a + test.b > Int64(10)]
+ "
+ )
+ }
+
+ /// verifies that a single predicate spanning a column key and an
expression
+ /// key is pushed: it is constant within every partition, but neither the
old
+ /// column-name matching nor a rule that looked only at expression keys
could
+ /// see that
+ #[test]
+ fn filter_move_window_mixed_column_and_expression_keys() -> Result<()> {
+ let table_scan = test_table_scan()?;
+
+ let window = Expr::from(WindowFunction::new(
+ WindowFunctionDefinition::WindowUDF(
+ datafusion_functions_window::rank::rank_udwf(),
+ ),
+ vec![],
+ ))
+ .partition_by(vec![col("a"), add(col("a"), col("b"))]) // PARTITION BY
a, a + b
+ .order_by(vec![col("c").sort(true, true)])
+ .build()
+ .unwrap();
+
+ let plan = LogicalPlanBuilder::from(table_scan)
+ .window(vec![window])?
+ .filter(
+ col("a")
+ .gt(lit(1i64))
+ .or(add(col("a"), col("b")).gt(lit(10i64))),
+ )?
+ .build()?;
+
+ assert_optimized_plan_equal!(
+ plan,
+ @r"
+ WindowAggr: windowExpr=[[rank() PARTITION BY [test.a, test.a + test.b]
ORDER BY [test.c ASC NULLS FIRST] ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT
ROW]]
+ TableScan: test, full_filters=[test.a > Int64(1) OR test.a + test.b
> Int64(10)]
+ "
+ )
+ }
+
+ /// verifies that an expression partition key does not make the columns it
+ /// reads pushable on their own: `a` alone is not constant within a
partition
+ /// of `a + b`
+ #[test]
+ fn filter_keep_window_column_underlying_expression_key() -> Result<()> {
+ let table_scan = test_table_scan()?;
+
+ let window = Expr::from(WindowFunction::new(
+ WindowFunctionDefinition::WindowUDF(
+ datafusion_functions_window::rank::rank_udwf(),
+ ),
+ vec![],
+ ))
+ .partition_by(vec![add(col("a"), col("b"))]) // PARTITION BY a + b
+ .order_by(vec![col("c").sort(true, true)])
+ .build()
+ .unwrap();
+
+ let plan = LogicalPlanBuilder::from(table_scan)
+ .window(vec![window])?
+ .filter(col("a").gt(lit(10i64)))?
+ .build()?;
+ assert_plan_not_transformed!(plan.clone());
+
+ assert_optimized_plan_equal!(
+ plan,
+ @r"
+ Filter: test.a > Int64(10)
+ WindowAggr: windowExpr=[[rank() PARTITION BY [test.a + test.b] ORDER
BY [test.c ASC NULLS FIRST] ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW]]
+ TableScan: test
+ "
+ )
+ }
+
+ /// verifies that a predicate on a *function* expression key is pushed.
This is
+ /// the shape the rule exists for: `PARTITION BY NULLIF(c, '')` with a
predicate
+ /// on `NULLIF(c, '')`, where the key is not an arithmetic expression and
the
+ /// column it reads is not a key on its own.
+ #[test]
+ fn filter_move_window_function_expression_key() -> Result<()> {
+ let table_scan = test_table_scan()?;
+
+ let window = Expr::from(WindowFunction::new(
+ WindowFunctionDefinition::WindowUDF(
+ datafusion_functions_window::rank::rank_udwf(),
+ ),
+ vec![],
+ ))
+ .partition_by(vec![immutable_udf_call(col("c"))])
+ .order_by(vec![col("c").sort(true, true)])
+ .build()
+ .unwrap();
+
+ let plan = LogicalPlanBuilder::from(table_scan)
+ .window(vec![window])?
+ .filter(immutable_udf_call(col("c")).is_not_null())?
+ .build()?;
+
+ assert_optimized_plan_equal!(
+ plan,
+ @r"
+ WindowAggr: windowExpr=[[rank() PARTITION BY [TestScalarUDF(test.c)]
ORDER BY [test.c ASC NULLS FIRST] ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT
ROW]]
+ TableScan: test, full_filters=[TestScalarUDF(test.c) IS NOT NULL]
+ "
+ )
+ }
+
+ /// verifies that matching is structural, so a predicate written with the
+ /// operands in the other order does not match the key and stays above the
+ /// window. Conservative rather than wrong.
+ #[test]
+ fn filter_keep_window_expression_key_operand_order() -> Result<()> {
+ let table_scan = test_table_scan()?;
+
+ let window = Expr::from(WindowFunction::new(
+ WindowFunctionDefinition::WindowUDF(
+ datafusion_functions_window::rank::rank_udwf(),
+ ),
+ vec![],
+ ))
+ .partition_by(vec![add(col("a"), col("b"))]) // PARTITION BY a + b
+ .order_by(vec![col("c").sort(true, true)])
+ .build()
+ .unwrap();
+
+ let plan = LogicalPlanBuilder::from(table_scan)
+ .window(vec![window])?
+ .filter(add(col("b"), col("a")).gt(lit(10i64)))? // b + a, not a +
b
+ .build()?;
+ assert_plan_not_transformed!(plan.clone());
+
+ assert_optimized_plan_equal!(
+ plan,
+ @r"
+ Filter: test.b + test.a > Int64(10)
+ WindowAggr: windowExpr=[[rank() PARTITION BY [test.a + test.b] ORDER
BY [test.c ASC NULLS FIRST] ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW]]
+ TableScan: test
+ "
+ )
+ }
+
+ /// verifies that a predicate built *on top of* an expression key is
pushed:
+ /// the key subtree accounts for everything it reads, and the literal
around it
+ /// reads nothing, so the predicate is still constant within each partition
+ #[test]
+ fn filter_move_window_expression_key_subtree() -> Result<()> {
+ let table_scan = test_table_scan()?;
+
+ let window = Expr::from(WindowFunction::new(
+ WindowFunctionDefinition::WindowUDF(
+ datafusion_functions_window::rank::rank_udwf(),
+ ),
+ vec![],
+ ))
+ .partition_by(vec![add(col("a"), col("b"))]) // PARTITION BY a + b
+ .order_by(vec![col("c").sort(true, true)])
+ .build()
+ .unwrap();
+
+ let plan = LogicalPlanBuilder::from(table_scan)
+ .window(vec![window])?
+ .filter(add(add(col("a"), col("b")), lit(1i64)).gt(lit(10i64)))?
Review Comment:
would help here to note with `((a + b) + 1) > 10`
##########
datafusion/optimizer/src/push_down_filter.rs:
##########
@@ -1975,21 +2044,449 @@ mod tests {
let plan = LogicalPlanBuilder::from(table_scan)
.window(vec![window])?
- // unlike with aggregations, single partition column "test.a +
test.b" is not available
- // to the plan, so we use multiple columns when filtering
+ // the single partition column "test.a + test.b" is not available
to the
+ // plan, so the predicate is written over the underlying columns
.filter(add(col("a"), col("b")).gt(lit(10i64)))?
.build()?;
+ assert_optimized_plan_equal!(
+ plan,
+ @r"
+ WindowAggr: windowExpr=[[rank() PARTITION BY [test.a + test.b] ORDER
BY [test.c ASC NULLS FIRST] ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW]]
+ TableScan: test, full_filters=[test.a + test.b > Int64(10)]
+ "
+ )
+ }
+
+ /// verifies that a single predicate spanning a column key and an
expression
+ /// key is pushed: it is constant within every partition, but neither the
old
+ /// column-name matching nor a rule that looked only at expression keys
could
+ /// see that
+ #[test]
+ fn filter_move_window_mixed_column_and_expression_keys() -> Result<()> {
+ let table_scan = test_table_scan()?;
+
+ let window = Expr::from(WindowFunction::new(
+ WindowFunctionDefinition::WindowUDF(
+ datafusion_functions_window::rank::rank_udwf(),
+ ),
+ vec![],
+ ))
+ .partition_by(vec![col("a"), add(col("a"), col("b"))]) // PARTITION BY
a, a + b
+ .order_by(vec![col("c").sort(true, true)])
+ .build()
+ .unwrap();
+
+ let plan = LogicalPlanBuilder::from(table_scan)
+ .window(vec![window])?
+ .filter(
+ col("a")
+ .gt(lit(1i64))
+ .or(add(col("a"), col("b")).gt(lit(10i64))),
+ )?
+ .build()?;
+
+ assert_optimized_plan_equal!(
+ plan,
+ @r"
+ WindowAggr: windowExpr=[[rank() PARTITION BY [test.a, test.a + test.b]
ORDER BY [test.c ASC NULLS FIRST] ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT
ROW]]
+ TableScan: test, full_filters=[test.a > Int64(1) OR test.a + test.b
> Int64(10)]
+ "
+ )
+ }
+
+ /// verifies that an expression partition key does not make the columns it
+ /// reads pushable on their own: `a` alone is not constant within a
partition
+ /// of `a + b`
+ #[test]
+ fn filter_keep_window_column_underlying_expression_key() -> Result<()> {
+ let table_scan = test_table_scan()?;
+
+ let window = Expr::from(WindowFunction::new(
+ WindowFunctionDefinition::WindowUDF(
+ datafusion_functions_window::rank::rank_udwf(),
+ ),
+ vec![],
+ ))
+ .partition_by(vec![add(col("a"), col("b"))]) // PARTITION BY a + b
+ .order_by(vec![col("c").sort(true, true)])
+ .build()
+ .unwrap();
+
+ let plan = LogicalPlanBuilder::from(table_scan)
+ .window(vec![window])?
+ .filter(col("a").gt(lit(10i64)))?
+ .build()?;
+ assert_plan_not_transformed!(plan.clone());
+
+ assert_optimized_plan_equal!(
+ plan,
+ @r"
+ Filter: test.a > Int64(10)
+ WindowAggr: windowExpr=[[rank() PARTITION BY [test.a + test.b] ORDER
BY [test.c ASC NULLS FIRST] ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW]]
+ TableScan: test
+ "
+ )
+ }
+
+ /// verifies that a predicate on a *function* expression key is pushed.
This is
+ /// the shape the rule exists for: `PARTITION BY NULLIF(c, '')` with a
predicate
+ /// on `NULLIF(c, '')`, where the key is not an arithmetic expression and
the
+ /// column it reads is not a key on its own.
+ #[test]
+ fn filter_move_window_function_expression_key() -> Result<()> {
+ let table_scan = test_table_scan()?;
+
+ let window = Expr::from(WindowFunction::new(
+ WindowFunctionDefinition::WindowUDF(
+ datafusion_functions_window::rank::rank_udwf(),
+ ),
+ vec![],
+ ))
+ .partition_by(vec![immutable_udf_call(col("c"))])
+ .order_by(vec![col("c").sort(true, true)])
+ .build()
+ .unwrap();
+
+ let plan = LogicalPlanBuilder::from(table_scan)
+ .window(vec![window])?
+ .filter(immutable_udf_call(col("c")).is_not_null())?
+ .build()?;
+
+ assert_optimized_plan_equal!(
+ plan,
+ @r"
+ WindowAggr: windowExpr=[[rank() PARTITION BY [TestScalarUDF(test.c)]
ORDER BY [test.c ASC NULLS FIRST] ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT
ROW]]
+ TableScan: test, full_filters=[TestScalarUDF(test.c) IS NOT NULL]
+ "
+ )
+ }
+
+ /// verifies that matching is structural, so a predicate written with the
+ /// operands in the other order does not match the key and stays above the
+ /// window. Conservative rather than wrong.
+ #[test]
+ fn filter_keep_window_expression_key_operand_order() -> Result<()> {
+ let table_scan = test_table_scan()?;
+
+ let window = Expr::from(WindowFunction::new(
+ WindowFunctionDefinition::WindowUDF(
+ datafusion_functions_window::rank::rank_udwf(),
+ ),
+ vec![],
+ ))
+ .partition_by(vec![add(col("a"), col("b"))]) // PARTITION BY a + b
+ .order_by(vec![col("c").sort(true, true)])
+ .build()
+ .unwrap();
+
+ let plan = LogicalPlanBuilder::from(table_scan)
+ .window(vec![window])?
+ .filter(add(col("b"), col("a")).gt(lit(10i64)))? // b + a, not a +
b
+ .build()?;
+ assert_plan_not_transformed!(plan.clone());
+
+ assert_optimized_plan_equal!(
+ plan,
+ @r"
+ Filter: test.b + test.a > Int64(10)
+ WindowAggr: windowExpr=[[rank() PARTITION BY [test.a + test.b] ORDER
BY [test.c ASC NULLS FIRST] ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW]]
+ TableScan: test
+ "
+ )
+ }
+
+ /// verifies that a predicate built *on top of* an expression key is
pushed:
+ /// the key subtree accounts for everything it reads, and the literal
around it
+ /// reads nothing, so the predicate is still constant within each partition
+ #[test]
+ fn filter_move_window_expression_key_subtree() -> Result<()> {
+ let table_scan = test_table_scan()?;
+
+ let window = Expr::from(WindowFunction::new(
+ WindowFunctionDefinition::WindowUDF(
+ datafusion_functions_window::rank::rank_udwf(),
+ ),
+ vec![],
+ ))
+ .partition_by(vec![add(col("a"), col("b"))]) // PARTITION BY a + b
+ .order_by(vec![col("c").sort(true, true)])
+ .build()
+ .unwrap();
+
+ let plan = LogicalPlanBuilder::from(table_scan)
+ .window(vec![window])?
+ .filter(add(add(col("a"), col("b")), lit(1i64)).gt(lit(10i64)))?
+ .build()?;
+
+ assert_optimized_plan_equal!(
+ plan,
+ @r"
+ WindowAggr: windowExpr=[[rank() PARTITION BY [test.a + test.b] ORDER
BY [test.c ASC NULLS FIRST] ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW]]
+ TableScan: test, full_filters=[test.a + test.b + Int64(1) >
Int64(10)]
+ "
+ )
+ }
+
+ /// verifies that an expression key shared by every window function is
pushed,
Review Comment:
I also recommend a test that has multiple instances of expressions in
partition by
```sql
PARTITION BY (a+b)
...
WHERE ((a +b) > 5 OR (a + b) < 10)
```
##########
datafusion/optimizer/src/push_down_filter.rs:
##########
@@ -1975,21 +2044,449 @@ mod tests {
let plan = LogicalPlanBuilder::from(table_scan)
.window(vec![window])?
- // unlike with aggregations, single partition column "test.a +
test.b" is not available
- // to the plan, so we use multiple columns when filtering
+ // the single partition column "test.a + test.b" is not available
to the
+ // plan, so the predicate is written over the underlying columns
.filter(add(col("a"), col("b")).gt(lit(10i64)))?
.build()?;
+ assert_optimized_plan_equal!(
+ plan,
+ @r"
+ WindowAggr: windowExpr=[[rank() PARTITION BY [test.a + test.b] ORDER
BY [test.c ASC NULLS FIRST] ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW]]
+ TableScan: test, full_filters=[test.a + test.b > Int64(10)]
+ "
+ )
+ }
+
+ /// verifies that a single predicate spanning a column key and an
expression
+ /// key is pushed: it is constant within every partition, but neither the
old
Review Comment:
again, a reference to the old code is not helpful in code comments -- it is
helpful in the context of a PR and I think should be a comment on the PR
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]