adriangb opened a new issue, #24109:
URL: https://github.com/apache/datafusion/issues/24109

   ### Describe the bug
   
   With `datafusion.execution.parquet.pushdown_filters = true`, a predicate on 
a struct field is reported as fully handled by the scan — so `FilterExec` is 
removed from the plan — and then never applied. The query returns **every row**.
   
   The trigger is not `get_field` itself; it is the expression adapter 
interposing a cast on the struct column, which happens whenever the table's 
declared schema differs from the file's physical schema for that column.
   
   ### To Reproduce
   
   ```sql
   -- file written with: id Int64, s Struct<x Int64, y Utf8>, v Int64, three 
rows, x = 100/200/300
   
   CREATE EXTERNAL TABLE fullt (id INT, s STRUCT<x INT, y VARCHAR>, v INT)
   STORED AS PARQUET LOCATION '.../w.parquet';
   
   set datafusion.execution.parquet.pushdown_filters = true;
   
   SELECT id FROM fullt WHERE v = 20;         -- correct: 1 row
   SELECT id FROM fullt WHERE s['x'] = 200;   -- WRONG: 3 rows
   ```
   
   The same query against a table whose schema is *inferred* from the file — so 
no cast is inserted — filters correctly. Here the declared `INT` differs from 
the file's `Int64`, and `VARCHAR` maps to `Utf8View` against the file's `Utf8`, 
so a cast is inserted.
   
   `EXPLAIN` for the broken case shows no `FilterExec` anywhere; the scan has 
claimed the predicate:
   
   ```
   logical_plan
   01)Projection: fullt.id
   02)--Filter: __datafusion_extracted_1 = Int32(200)
   03)----Projection: get_field(fullt.s, Utf8("x")) AS 
__datafusion_extracted_1, fullt.id
   04)------TableScan: fullt projection=[id, s], 
partial_filters=[get_field(fullt.s, Utf8("x")) = Int32(200)]
   physical_plan DataSourceExec: ... projection=[id, s], 
predicate=get_field(s@1, x) = 200
   ```
   
   ### Expected behavior
   
   One row. A predicate the scan reports as exactly handled must actually be 
applied.
   
   ### Mechanism
   
   1. `ParquetSource::try_pushdown_filters` 
(`datasource-parquet/src/source.rs`) decides exactness with 
`can_expr_be_pushed_down_with_schemas(&filter, pushable_schema)`, where 
`pushable_schema` is the **table** schema. Against it the predicate is 
`get_field(Column("s"), 'x') = 200`, which `PushdownChecker` accepts, so the 
filter is marked `PushedDown::Yes` and `FilterExec` is removed.
   2. At file-open time the physical expression adapter rewrites the predicate 
against the **physical file** schema, producing `get_field(CAST(s AS Struct{x: 
Int32, y: Utf8View}), 'x') = 200`.
   3. `PushdownChecker::f_down` only recognises `get_field` whose first 
argument is a bare `Column`. Here it is a `CastExpr`, so the visitor descends 
past it, reaches `Column("s")`, sees a `Struct`, sets `non_primitive_columns = 
true`, and `pushdown_columns` returns `None`.
   4. `build_row_filter` collects no candidate for that conjunct and returns 
`Ok(None)`. The conjunct is dropped, and nothing downstream re-applies it — 
step 1 already removed `FilterExec`.
   
   ### This is an instance of a general category
   
   The specific `get_field`-over-a-cast case is easy enough to patch, but the 
shape of the bug will recur: **a static determination made at planning time 
about what the scan can do, and the runtime construction that has to make good 
on it, are computed by different code against different schemas, and there is 
no mechanism forcing them to agree.** Planning decides against the table 
schema; the row filter is built per file against that file's physical schema 
after adaptation. Any divergence between the two turns a "predicate handled 
exactly" promise into a silently dropped predicate.
   
   The codebase already knows this hazard exists and patches it case by case — 
`validate_predicate_does_not_reference_virtual_columns` in `opener/mod.rs` 
exists for exactly this reason, and its doc comment says "Silently dropping 
such a predicate would produce wrong results." That is one instance guarded by 
hand; this is another; there are likely more, and each new pushdown-eligible 
expression shape is a chance to add one.
   
   The structural fix is to remove the cliff: **let `ParquetOpener` apply a 
predicate as an ordinary post-decode filter when it cannot be turned into a row 
filter.** Then failing to build a row filter — for any reason, known or not yet 
discovered — costs at most some performance instead of correctness, and the 
planner's "exactly handled" claim stays true regardless of what the per-file 
construction manages to do. Individual cases can then be optimised into row 
filters as they are understood, rather than each unhandled case being a latent 
wrong-results bug.
   
   ### Additional context
   
   Found while reviewing apache/datafusion#24090 (nested projection pruning for 
Parquet). That PR does not cause this — `row_filter.rs` is untouched by it and 
the failing query takes an unchanged code path — but it is worth flagging 
alongside it, because a table whose declared nested schema is narrower than the 
file's *always* carries the adapter cast that triggers this. So on exactly the 
tables that feature targets, any `WHERE s['field'] = ...` with 
`pushdown_filters = true` is wrong today.
   
   It is also why nested pruning cannot simply be extended to the filter path 
as-is: filter pushdown over a narrowed nested column is currently unsound, not 
merely unoptimised. Teaching `PushdownChecker` to look through the cast would 
address both, but only if the fallback above exists to catch whatever it still 
misses.
   
   cc @alamb
   
   ---
   _Generated by [Claude Code](https://claude.ai/code)_
   


-- 
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]

Reply via email to