Skip to content

Commit 43fbf0f

Browse files
adriangbclaude
andcommitted
test: cover the filter and extraction projection precedence
Unit tests in `push_down_filter.rs`: a filter stays above a pure extraction projection, and a projection that also computes an expression is still pushed through. A new section in `projection_pushdown.slt` with the query from #14540 on a memory table and on a Parquet file. The Parquet plan shows both properties the precedence must keep: `DataSourceExec` reads only the struct leaf `ids.id1`, and the `date` predicate reaches the scan for row group pruning. The section also pins the plan at a reduced `datafusion.optimizer.max_passes`. The simple shape gives the same plan at one pass as at the default. The issue shape gives the same plan at two passes, because the two filters merge in the second pass. Neither plan depends on the pass limit, so neither depends on which of the two rules runs last. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 115a4f8 commit 43fbf0f

2 files changed

Lines changed: 285 additions & 0 deletions

File tree

‎datafusion/optimizer/src/push_down_filter.rs‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4532,6 +4532,70 @@ mod tests {
45324532
)
45334533
}
45344534

4535+
/// A filter is not moved below a pure extraction projection, even when its
4536+
/// predicate only references pass-through columns.
4537+
///
4538+
/// `PushDownLeafProjections` moves such a projection back below the filter,
4539+
/// so pushing here would make the two rules undo each other on every
4540+
/// optimizer pass. See <https://github.com/apache/datafusion/issues/14540>
4541+
/// and the module documentation.
4542+
#[test]
4543+
fn filter_not_pushed_through_pure_extraction_projection() -> Result<()> {
4544+
let table_scan = test_table_scan()?;
4545+
4546+
// The shape `ExtractLeafExpressions` produces: one extraction alias
4547+
// plus pass-through columns.
4548+
let proj = LogicalPlanBuilder::from(table_scan)
4549+
.project(vec![
4550+
leaf_udf_expr(col("a")).alias("__datafusion_extracted_1"),
4551+
col("b"),
4552+
col("c"),
4553+
])?
4554+
.build()?;
4555+
4556+
// `b` is a plain pass-through column, so without the precedence rule
4557+
// this predicate would reach the scan as a `full_filters` entry.
4558+
let plan = LogicalPlanBuilder::from(proj)
4559+
.filter(col("b").gt(lit(5i64)))?
4560+
.build()?;
4561+
4562+
assert_optimized_plan_equal!(
4563+
plan,
4564+
@r"
4565+
Filter: test.b > Int64(5)
4566+
Projection: leaf_udf(test.a) AS __datafusion_extracted_1, test.b, test.c
4567+
TableScan: test
4568+
"
4569+
)
4570+
}
4571+
4572+
/// A projection that mixes an extraction alias with a computed expression is
4573+
/// not a pure extraction projection, so the filter still moves below it.
4574+
#[test]
4575+
fn filter_pushed_through_mixed_extraction_projection() -> Result<()> {
4576+
let table_scan = test_table_scan()?;
4577+
4578+
let proj = LogicalPlanBuilder::from(table_scan)
4579+
.project(vec![
4580+
leaf_udf_expr(col("a")).alias("__datafusion_extracted_1"),
4581+
(col("b") + lit(1i64)).alias("b_plus"),
4582+
col("c"),
4583+
])?
4584+
.build()?;
4585+
4586+
let plan = LogicalPlanBuilder::from(proj)
4587+
.filter(col("c").gt(lit(5i64)))?
4588+
.build()?;
4589+
4590+
assert_optimized_plan_equal!(
4591+
plan,
4592+
@r"
4593+
Projection: leaf_udf(test.a) AS __datafusion_extracted_1, test.b + Int64(1) AS b_plus, test.c
4594+
TableScan: test, full_filters=[test.c > Int64(5)]
4595+
"
4596+
)
4597+
}
4598+
45354599
#[test]
45364600
fn filter_not_pushed_down_through_table_scan_with_fetch() -> Result<()> {
45374601
let scan = test_table_scan()?;

‎datafusion/sqllogictest/test_files/projection_pushdown.slt‎

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2340,3 +2340,224 @@ logical_plan
23402340

23412341
statement ok
23422342
set datafusion.explain.logical_plan_only = false;
2343+
2344+
#####################
2345+
# Section 17: Precedence between PushDownFilter and PushDownLeafProjections
2346+
#
2347+
# Both rules move nodes towards the leaves, and for an adjacent filter and
2348+
# "pure extraction projection" they want the opposite order. The precedence is:
2349+
# the extraction projection wins. It stays next to the scan, so the scan can
2350+
# absorb it and read only the struct leaf, and the filter stays above it.
2351+
#
2352+
# See https://github.com/apache/datafusion/issues/14540
2353+
#####################
2354+
2355+
statement ok
2356+
SET datafusion.execution.target_partitions = 1;
2357+
2358+
statement ok
2359+
CREATE TABLE events_mem (
2360+
"date" DATE,
2361+
"timestamp" TIMESTAMP,
2362+
ids STRUCT<id1 VARCHAR, extra INT>,
2363+
structs STRUCT<var1 VARCHAR, extra VARCHAR>
2364+
) AS VALUES
2365+
(DATE '2025-01-03', TIMESTAMP '2025-01-03 01:00:00', {id1: 'dev1', extra: 1}, {var1: 'user1', extra: 'e1'}),
2366+
(DATE '2025-01-03', TIMESTAMP '2025-01-03 02:00:00', {id1: 'dev1', extra: 2}, {var1: 'user2', extra: 'e2'}),
2367+
(DATE '2025-01-04', TIMESTAMP '2025-01-04 01:00:00', {id1: 'dev2', extra: 3}, {var1: 'user3', extra: 'e3'});
2368+
2369+
# The query from #14540. The extraction projection is the bottom node and the
2370+
# filter sits above it.
2371+
query TT
2372+
EXPLAIN WITH events AS (
2373+
SELECT ids.id1 AS device, structs.var1 AS user, "timestamp"
2374+
FROM events_mem
2375+
WHERE "date" = '2025-01-03'
2376+
)
2377+
SELECT *, LAG(user, 1) OVER (PARTITION BY device ORDER BY "timestamp") AS prev
2378+
FROM events
2379+
WHERE device IS NOT NULL AND device != '' AND user IS NOT NULL AND user != ''
2380+
LIMIT 100;
2381+
----
2382+
logical_plan
2383+
01)Projection: events.device, events.user, events.timestamp, lag(events.user,Int64(1)) PARTITION BY [events.device] ORDER BY [events.timestamp ASC NULLS LAST] RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW AS prev
2384+
02)--Limit: skip=0, fetch=100
2385+
03)----WindowAggr: windowExpr=[[lag(events.user, Int64(1)) PARTITION BY [events.device] ORDER BY [events.timestamp ASC NULLS LAST] RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW]]
2386+
04)------SubqueryAlias: events
2387+
05)--------Projection: __datafusion_extracted_1 AS device, __datafusion_extracted_2 AS user, events_mem.timestamp
2388+
06)----------Filter: events_mem.date = Date32("2025-01-03") AND __datafusion_extracted_1 IS NOT NULL AND __datafusion_extracted_1 != Utf8View("") AND __datafusion_extracted_2 IS NOT NULL AND __datafusion_extracted_2 != Utf8View("")
2389+
07)------------Projection: get_field(events_mem.ids, Utf8("id1")) AS __datafusion_extracted_1, get_field(events_mem.structs, Utf8("var1")) AS __datafusion_extracted_2, events_mem.date, events_mem.timestamp
2390+
08)--------------TableScan: events_mem projection=[date, timestamp, ids, structs]
2391+
physical_plan
2392+
01)ProjectionExec: expr=[device@0 as device, user@1 as user, timestamp@2 as timestamp, lag(events.user,Int64(1)) PARTITION BY [events.device] ORDER BY [events.timestamp ASC NULLS LAST] RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW@3 as prev]
2393+
02)--GlobalLimitExec: skip=0, fetch=100
2394+
03)----BoundedWindowAggExec: wdw=[lag(events.user,Int64(1)) PARTITION BY [events.device] ORDER BY [events.timestamp ASC NULLS LAST] RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW: Field { "lag(events.user,Int64(1)) PARTITION BY [events.device] ORDER BY [events.timestamp ASC NULLS LAST] RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW": nullable Utf8View }, frame: RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW], mode=[Sorted]
2395+
04)------SortExec: expr=[device@0 ASC NULLS LAST, timestamp@2 ASC NULLS LAST], preserve_partitioning=[false]
2396+
05)--------ProjectionExec: expr=[__datafusion_extracted_1@0 as device, __datafusion_extracted_2@1 as user, timestamp@2 as timestamp]
2397+
06)----------FilterExec: date@2 = 2025-01-03 AND __datafusion_extracted_1@0 IS NOT NULL AND __datafusion_extracted_1@0 != AND __datafusion_extracted_2@1 IS NOT NULL AND __datafusion_extracted_2@1 != , projection=[__datafusion_extracted_1@0, __datafusion_extracted_2@1, timestamp@3]
2398+
07)------------ProjectionExec: expr=[get_field(ids@2, id1) as __datafusion_extracted_1, get_field(structs@3, var1) as __datafusion_extracted_2, date@0 as date, timestamp@1 as timestamp]
2399+
08)--------------DataSourceExec: partitions=1, partition_sizes=[1]
2400+
2401+
# Determinism: the plan is a fixed point reached before the default pass limit,
2402+
# so it does not depend on which of the two rules runs last. One pass is enough
2403+
# for the simple shape.
2404+
statement ok
2405+
SET datafusion.optimizer.max_passes = 1;
2406+
2407+
query TT
2408+
EXPLAIN SELECT ids['id1'] FROM events_mem WHERE "date" = '2025-01-03';
2409+
----
2410+
logical_plan
2411+
01)Projection: __datafusion_extracted_1 AS events_mem.ids[id1]
2412+
02)--Filter: events_mem.date = Date32("2025-01-03")
2413+
03)----Projection: get_field(events_mem.ids, Utf8("id1")) AS __datafusion_extracted_1, events_mem.date
2414+
04)------TableScan: events_mem projection=[date, ids]
2415+
physical_plan
2416+
01)ProjectionExec: expr=[__datafusion_extracted_1@0 as events_mem.ids[id1]]
2417+
02)--FilterExec: date@1 = 2025-01-03, projection=[__datafusion_extracted_1@0]
2418+
03)----ProjectionExec: expr=[get_field(ids@1, id1) as __datafusion_extracted_1, date@0 as date]
2419+
04)------DataSourceExec: partitions=1, partition_sizes=[1]
2420+
2421+
statement ok
2422+
RESET datafusion.optimizer.max_passes;
2423+
2424+
query TT
2425+
EXPLAIN SELECT ids['id1'] FROM events_mem WHERE "date" = '2025-01-03';
2426+
----
2427+
logical_plan
2428+
01)Projection: __datafusion_extracted_1 AS events_mem.ids[id1]
2429+
02)--Filter: events_mem.date = Date32("2025-01-03")
2430+
03)----Projection: get_field(events_mem.ids, Utf8("id1")) AS __datafusion_extracted_1, events_mem.date
2431+
04)------TableScan: events_mem projection=[date, ids]
2432+
physical_plan
2433+
01)ProjectionExec: expr=[__datafusion_extracted_1@0 as events_mem.ids[id1]]
2434+
02)--FilterExec: date@1 = 2025-01-03, projection=[__datafusion_extracted_1@0]
2435+
03)----ProjectionExec: expr=[get_field(ids@1, id1) as __datafusion_extracted_1, date@0 as date]
2436+
04)------DataSourceExec: partitions=1, partition_sizes=[1]
2437+
2438+
# The #14540 shape needs a second pass, because the two filters merge into one.
2439+
# It is stable from that pass on.
2440+
statement ok
2441+
SET datafusion.optimizer.max_passes = 2;
2442+
2443+
query TT
2444+
EXPLAIN WITH events AS (
2445+
SELECT ids.id1 AS device, structs.var1 AS user, "timestamp"
2446+
FROM events_mem
2447+
WHERE "date" = '2025-01-03'
2448+
)
2449+
SELECT *, LAG(user, 1) OVER (PARTITION BY device ORDER BY "timestamp") AS prev
2450+
FROM events
2451+
WHERE device IS NOT NULL AND device != '' AND user IS NOT NULL AND user != ''
2452+
LIMIT 100;
2453+
----
2454+
logical_plan
2455+
01)Projection: events.device, events.user, events.timestamp, lag(events.user,Int64(1)) PARTITION BY [events.device] ORDER BY [events.timestamp ASC NULLS LAST] RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW AS prev
2456+
02)--Limit: skip=0, fetch=100
2457+
03)----WindowAggr: windowExpr=[[lag(events.user, Int64(1)) PARTITION BY [events.device] ORDER BY [events.timestamp ASC NULLS LAST] RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW]]
2458+
04)------SubqueryAlias: events
2459+
05)--------Projection: __datafusion_extracted_1 AS device, __datafusion_extracted_2 AS user, events_mem.timestamp
2460+
06)----------Filter: events_mem.date = Date32("2025-01-03") AND __datafusion_extracted_1 IS NOT NULL AND __datafusion_extracted_1 != Utf8View("") AND __datafusion_extracted_2 IS NOT NULL AND __datafusion_extracted_2 != Utf8View("")
2461+
07)------------Projection: get_field(events_mem.ids, Utf8("id1")) AS __datafusion_extracted_1, get_field(events_mem.structs, Utf8("var1")) AS __datafusion_extracted_2, events_mem.date, events_mem.timestamp
2462+
08)--------------TableScan: events_mem projection=[date, timestamp, ids, structs]
2463+
physical_plan
2464+
01)ProjectionExec: expr=[device@0 as device, user@1 as user, timestamp@2 as timestamp, lag(events.user,Int64(1)) PARTITION BY [events.device] ORDER BY [events.timestamp ASC NULLS LAST] RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW@3 as prev]
2465+
02)--GlobalLimitExec: skip=0, fetch=100
2466+
03)----BoundedWindowAggExec: wdw=[lag(events.user,Int64(1)) PARTITION BY [events.device] ORDER BY [events.timestamp ASC NULLS LAST] RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW: Field { "lag(events.user,Int64(1)) PARTITION BY [events.device] ORDER BY [events.timestamp ASC NULLS LAST] RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW": nullable Utf8View }, frame: RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW], mode=[Sorted]
2467+
04)------SortExec: expr=[device@0 ASC NULLS LAST, timestamp@2 ASC NULLS LAST], preserve_partitioning=[false]
2468+
05)--------ProjectionExec: expr=[__datafusion_extracted_1@0 as device, __datafusion_extracted_2@1 as user, timestamp@2 as timestamp]
2469+
06)----------FilterExec: date@2 = 2025-01-03 AND __datafusion_extracted_1@0 IS NOT NULL AND __datafusion_extracted_1@0 != AND __datafusion_extracted_2@1 IS NOT NULL AND __datafusion_extracted_2@1 != , projection=[__datafusion_extracted_1@0, __datafusion_extracted_2@1, timestamp@3]
2470+
07)------------ProjectionExec: expr=[get_field(ids@2, id1) as __datafusion_extracted_1, get_field(structs@3, var1) as __datafusion_extracted_2, date@0 as date, timestamp@1 as timestamp]
2471+
08)--------------DataSourceExec: partitions=1, partition_sizes=[1]
2472+
2473+
statement ok
2474+
RESET datafusion.optimizer.max_passes;
2475+
2476+
query TTT
2477+
WITH events AS (
2478+
SELECT ids.id1 AS device, structs.var1 AS user, "timestamp"
2479+
FROM events_mem
2480+
WHERE "date" = '2025-01-03'
2481+
)
2482+
SELECT device, user, CAST(LAG(user, 1) OVER (PARTITION BY device ORDER BY "timestamp") AS VARCHAR) AS prev
2483+
FROM events
2484+
WHERE device IS NOT NULL AND device != '' AND user IS NOT NULL AND user != ''
2485+
ORDER BY user;
2486+
----
2487+
dev1 user1 NULL
2488+
dev1 user2 user1
2489+
2490+
statement ok
2491+
DROP TABLE events_mem;
2492+
2493+
# Parquet: both properties must survive. `DataSourceExec` reads only the struct
2494+
# leaf `ids.id1` (the `get_field` is in the scan projection), and the `date`
2495+
# predicate reaches the scan for row group pruning.
2496+
statement ok
2497+
COPY (
2498+
SELECT
2499+
column1 AS "date",
2500+
column2 AS "timestamp",
2501+
column3 AS ids,
2502+
column4 AS structs
2503+
FROM VALUES
2504+
(DATE '2025-01-03', TIMESTAMP '2025-01-03 01:00:00', {id1: 'dev1', extra: 1}, {var1: 'user1', extra: 'e1'}),
2505+
(DATE '2025-01-03', TIMESTAMP '2025-01-03 02:00:00', {id1: 'dev1', extra: 2}, {var1: 'user2', extra: 'e2'}),
2506+
(DATE '2025-01-04', TIMESTAMP '2025-01-04 01:00:00', {id1: 'dev2', extra: 3}, {var1: 'user3', extra: 'e3'})
2507+
) TO 'test_files/scratch/projection_pushdown/events.parquet'
2508+
STORED AS PARQUET;
2509+
2510+
statement ok
2511+
CREATE EXTERNAL TABLE events_pq STORED AS PARQUET
2512+
LOCATION 'test_files/scratch/projection_pushdown/events.parquet';
2513+
2514+
query TT
2515+
EXPLAIN SELECT ids['id1'] FROM events_pq WHERE "date" = '2025-01-03';
2516+
----
2517+
logical_plan
2518+
01)Projection: __datafusion_extracted_1 AS events_pq.ids[id1]
2519+
02)--Filter: events_pq.date = Date32("2025-01-03")
2520+
03)----Projection: get_field(events_pq.ids, Utf8("id1")) AS __datafusion_extracted_1, events_pq.date
2521+
04)------TableScan: events_pq projection=[date, ids], partial_filters=[events_pq.date = Date32("2025-01-03")]
2522+
physical_plan
2523+
01)ProjectionExec: expr=[__datafusion_extracted_1@0 as events_pq.ids[id1]]
2524+
02)--FilterExec: date@1 = 2025-01-03, projection=[__datafusion_extracted_1@0]
2525+
03)----DataSourceExec: file_groups={1 group: [[WORKSPACE_ROOT/datafusion/sqllogictest/test_files/scratch/projection_pushdown/events.parquet]]}, projection=[get_field(ids@2, id1) as __datafusion_extracted_1, date], file_type=parquet, predicate=date@0 = 2025-01-03, pruning_predicate=date_null_count@2 != row_count@3 AND date_min@0 <= 2025-01-03 AND 2025-01-03 <= date_max@1, required_guarantees=[date in (2025-01-03)]
2526+
2527+
query TT
2528+
EXPLAIN WITH events AS (
2529+
SELECT ids.id1 AS device, structs.var1 AS user, "timestamp"
2530+
FROM events_pq
2531+
WHERE "date" = '2025-01-03'
2532+
)
2533+
SELECT *, LAG(user, 1) OVER (PARTITION BY device ORDER BY "timestamp") AS prev
2534+
FROM events
2535+
WHERE device IS NOT NULL AND device != '' AND user IS NOT NULL AND user != ''
2536+
LIMIT 100;
2537+
----
2538+
logical_plan
2539+
01)Projection: events.device, events.user, events.timestamp, lag(events.user,Int64(1)) PARTITION BY [events.device] ORDER BY [events.timestamp ASC NULLS LAST] RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW AS prev
2540+
02)--Limit: skip=0, fetch=100
2541+
03)----WindowAggr: windowExpr=[[lag(events.user, Int64(1)) PARTITION BY [events.device] ORDER BY [events.timestamp ASC NULLS LAST] RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW]]
2542+
04)------SubqueryAlias: events
2543+
05)--------Projection: __datafusion_extracted_1 AS device, __datafusion_extracted_2 AS user, events_pq.timestamp
2544+
06)----------Filter: events_pq.date = Date32("2025-01-03") AND __datafusion_extracted_1 IS NOT NULL AND __datafusion_extracted_1 != Utf8("") AND __datafusion_extracted_2 IS NOT NULL AND __datafusion_extracted_2 != Utf8("")
2545+
07)------------Projection: get_field(events_pq.ids, Utf8("id1")) AS __datafusion_extracted_1, get_field(events_pq.structs, Utf8("var1")) AS __datafusion_extracted_2, events_pq.date, events_pq.timestamp
2546+
08)--------------TableScan: events_pq projection=[date, timestamp, ids, structs], partial_filters=[events_pq.date = Date32("2025-01-03")]
2547+
physical_plan
2548+
01)ProjectionExec: expr=[device@0 as device, user@1 as user, timestamp@2 as timestamp, lag(events.user,Int64(1)) PARTITION BY [events.device] ORDER BY [events.timestamp ASC NULLS LAST] RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW@3 as prev]
2549+
02)--GlobalLimitExec: skip=0, fetch=100
2550+
03)----BoundedWindowAggExec: wdw=[lag(events.user,Int64(1)) PARTITION BY [events.device] ORDER BY [events.timestamp ASC NULLS LAST] RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW: Field { "lag(events.user,Int64(1)) PARTITION BY [events.device] ORDER BY [events.timestamp ASC NULLS LAST] RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW": nullable Utf8 }, frame: RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW], mode=[Sorted]
2551+
04)------SortExec: expr=[device@0 ASC NULLS LAST, timestamp@2 ASC NULLS LAST], preserve_partitioning=[false]
2552+
05)--------ProjectionExec: expr=[__datafusion_extracted_1@0 as device, __datafusion_extracted_2@1 as user, timestamp@2 as timestamp]
2553+
06)----------FilterExec: date@2 = 2025-01-03 AND __datafusion_extracted_1@0 IS NOT NULL AND __datafusion_extracted_1@0 != AND __datafusion_extracted_2@1 IS NOT NULL AND __datafusion_extracted_2@1 != , projection=[__datafusion_extracted_1@0, __datafusion_extracted_2@1, timestamp@3]
2554+
07)------------DataSourceExec: file_groups={1 group: [[WORKSPACE_ROOT/datafusion/sqllogictest/test_files/scratch/projection_pushdown/events.parquet]]}, projection=[get_field(ids@2, id1) as __datafusion_extracted_1, get_field(structs@3, var1) as __datafusion_extracted_2, date, timestamp], file_type=parquet, predicate=date@0 = 2025-01-03 AND get_field(ids@2, id1) IS NOT NULL AND get_field(ids@2, id1) != AND get_field(structs@3, var1) IS NOT NULL AND get_field(structs@3, var1) != , pruning_predicate=date_null_count@2 != row_count@3 AND date_min@0 <= 2025-01-03 AND 2025-01-03 <= date_max@1, required_guarantees=[date in (2025-01-03)]
2555+
2556+
statement ok
2557+
DROP TABLE events_pq;
2558+
2559+
statement ok
2560+
RESET datafusion.optimizer.max_passes;
2561+
2562+
statement ok
2563+
SET datafusion.execution.target_partitions = 4;

0 commit comments

Comments
 (0)