@@ -44,8 +44,8 @@ use datafusion_common::encryption::FileDecryptionProperties;
4444use datafusion_common:: stats:: Precision ;
4545use datafusion_common:: tree_node:: { TreeNode , TreeNodeRecursion } ;
4646use datafusion_common:: {
47- ColumnStatistics , DataFusionError , Result , ScalarValue , Statistics , exec_err ,
48- not_impl_err,
47+ ColumnStatistics , DataFusionError , HashSet , Result , ScalarValue , Statistics ,
48+ exec_err , not_impl_err,
4949} ;
5050use datafusion_datasource:: { PartitionedFile , TableSchema } ;
5151use datafusion_physical_expr:: expressions:: Column ;
@@ -1411,7 +1411,7 @@ fn append_fields(base: &SchemaRef, extra: &[FieldRef]) -> SchemaRef {
14111411/// stringly-typed allowlist here.
14121412fn validate_supported_virtual_columns ( virtual_columns : & [ FieldRef ] ) -> Result < ( ) > {
14131413 for field in virtual_columns {
1414- ParquetVirtualColumn :: try_from ( Arc :: clone ( field) ) ?;
1414+ ParquetVirtualColumn :: try_from ( field) ?;
14151415 }
14161416 Ok ( ( ) )
14171417}
@@ -1425,27 +1425,24 @@ fn validate_supported_virtual_columns(virtual_columns: &[FieldRef]) -> Result<()
14251425/// inside a row filter. Silently dropping such a predicate would produce wrong
14261426/// results, so we fail loudly here.
14271427///
1428- /// Callers constructing a `ParquetSource` should rely on
1429- /// `FileSource::try_pushdown_filters` (invoked by the `FilterPushdown` physical
1430- /// optimizer rule) to classify filters correctly: filters referencing virtual
1431- /// columns are reported as `PushedDown::No` and stay in the enclosing
1432- /// `FilterExec`, while the scan emits the virtual columns for the filter to
1433- /// consume. Callers building plans manually must keep the `FilterExec` above
1434- /// the `DataSourceExec` themselves rather than setting the predicate on
1435- /// `ParquetSource` with pushdown enabled.
1428+ /// `ParquetSource::try_pushdown_filters` already classifies virtual-column
1429+ /// filters as `PushedDown::No` so the `FilterPushdown` optimizer leaves them
1430+ /// above the scan; this check is defense-in-depth for callers that build plans
1431+ /// manually and set `with_pushdown_filters(true)` alongside a predicate
1432+ /// referencing virtual columns.
14361433fn validate_predicate_does_not_reference_virtual_columns (
14371434 predicate : & Arc < dyn PhysicalExpr > ,
14381435 virtual_columns : & [ FieldRef ] ,
14391436) -> Result < ( ) > {
14401437 if virtual_columns. is_empty ( ) {
14411438 return Ok ( ( ) ) ;
14421439 }
1440+ let virtual_names: HashSet < & str > =
1441+ virtual_columns. iter ( ) . map ( |f| f. name ( ) . as_str ( ) ) . collect ( ) ;
14431442 let mut offender: Option < String > = None ;
14441443 predicate. apply ( |node : & Arc < dyn PhysicalExpr > | {
14451444 if let Some ( column) = node. downcast_ref :: < Column > ( )
1446- && virtual_columns
1447- . iter ( )
1448- . any ( |f| f. name ( ) . as_str ( ) == column. name ( ) )
1445+ && virtual_names. contains ( column. name ( ) )
14491446 {
14501447 offender = Some ( column. name ( ) . to_string ( ) ) ;
14511448 return Ok ( TreeNodeRecursion :: Stop ) ;
@@ -1455,14 +1452,9 @@ fn validate_predicate_does_not_reference_virtual_columns(
14551452 if let Some ( name) = offender {
14561453 return not_impl_err ! (
14571454 "Predicate references virtual column '{name}' while \
1458- pushdown_filters=true. DataFusion cannot push predicates on \
1459- virtual columns into the Parquet reader: arrow-rs's RowFilter \
1460- operates on a ProjectionMask over file leaves, and virtual \
1461- columns (e.g. row_number) are synthesized by the reader after \
1462- filter evaluation. Either leave the filter in a FilterExec above \
1463- the scan (this is what `ParquetSource::try_pushdown_filters` / \
1464- the FilterPushdown optimizer rule will do automatically), or \
1465- disable pushdown via `with_pushdown_filters(false)`."
1455+ pushdown_filters=true; predicates on virtual columns must be \
1456+ evaluated above the scan. Disable filter pushdown or leave the \
1457+ filter above the scan."
14661458 ) ;
14671459 }
14681460 Ok ( ( ) )
@@ -3218,119 +3210,90 @@ mod test {
32183210 ) ;
32193211 }
32203212
3221- #[ tokio:: test]
3222- async fn test_row_index_predicate_pushdown_mixed_or_errors ( ) {
3223- // Mixed `row_number = 2 OR value = 4` with pushdown_filters=true.
3224- // Silently dropping this in the scan would return all 5 rows; we
3225- // instead surface a guidance-rich error so callers realize the
3226- // predicate needs to stay above the scan.
3227- let store = Arc :: new ( InMemory :: new ( ) ) as Arc < dyn ObjectStore > ;
3228- let ( file_schema, data_size) =
3229- write_grouped_file ( & store, "pushdown_mixed.parquet" , 1 , 5 ) . await ;
3230-
3213+ /// Build a morselizer + file for a 5-row single-row-group parquet at
3214+ /// `path`, with a single `row_number` virtual column and the given
3215+ /// physical predicate applied to
3216+ /// `table_schema = [value(0), row_number(1)]`.
3217+ async fn build_pushdown_morselizer (
3218+ store : & Arc < dyn ObjectStore > ,
3219+ path : & str ,
3220+ predicate_expr : datafusion_expr:: Expr ,
3221+ pushdown_filters : bool ,
3222+ ) -> ( ParquetMorselizer , PartitionedFile ) {
3223+ let ( file_schema, data_size) = write_grouped_file ( store, path, 1 , 5 ) . await ;
32313224 let rn_field = row_number_field ( "row_number" , false ) ;
32323225 let table_schema = TableSchema :: from_file_schema ( Arc :: clone ( & file_schema) )
32333226 . with_virtual_columns ( vec ! [ Arc :: clone( & rn_field) ] ) ;
32343227 let projection =
32353228 ProjectionExprs :: from_indices ( & [ 0 , 1 ] , table_schema. table_schema ( ) ) ;
3236-
3237- let expr = col ( "row_number" )
3238- . eq ( lit ( 2i64 ) )
3239- . or ( col ( "value" ) . eq ( lit ( 4i64 ) ) ) ;
3240- let predicate = logical2physical ( & expr, table_schema. table_schema ( ) ) ;
3229+ let predicate =
3230+ logical2physical ( & predicate_expr, table_schema. table_schema ( ) ) ;
32413231
32423232 let morselizer = ParquetMorselizerBuilder :: new ( )
3243- . with_store ( Arc :: clone ( & store) )
3233+ . with_store ( Arc :: clone ( store) )
32443234 . with_table_schema ( table_schema)
32453235 . with_projection ( projection)
32463236 . with_predicate ( predicate)
3247- . with_pushdown_filters ( true )
3237+ . with_pushdown_filters ( pushdown_filters )
32483238 . build ( ) ;
32493239
3250- let file = PartitionedFile :: new (
3251- "pushdown_mixed.parquet" . to_string ( ) ,
3252- u64:: try_from ( data_size) . unwrap ( ) ,
3253- ) ;
3240+ let file =
3241+ PartitionedFile :: new ( path. to_string ( ) , u64:: try_from ( data_size) . unwrap ( ) ) ;
3242+ ( morselizer, file)
3243+ }
3244+
3245+ #[ tokio:: test]
3246+ async fn test_row_index_predicate_pushdown_mixed_or_errors ( ) {
3247+ // Silent drop in the scan would return all 5 rows; we want a loud
3248+ // error instead.
3249+ let store = Arc :: new ( InMemory :: new ( ) ) as Arc < dyn ObjectStore > ;
3250+ let expr = col ( "row_number" )
3251+ . eq ( lit ( 2i64 ) )
3252+ . or ( col ( "value" ) . eq ( lit ( 4i64 ) ) ) ;
3253+ let ( morselizer, file) =
3254+ build_pushdown_morselizer ( & store, "pushdown_mixed.parquet" , expr, true )
3255+ . await ;
3256+
32543257 let err = morselizer. plan_file ( file) . unwrap_err ( ) ;
32553258 let msg = err. to_string ( ) ;
32563259 assert ! (
32573260 msg. contains( "row_number" ) ,
32583261 "error should name the offending virtual column, got: {msg}"
32593262 ) ;
32603263 assert ! (
3261- msg. contains( "arrow-rs" ) && msg. contains( "RowFilter" ) ,
3262- "error should explain the arrow-rs limitation, got: {msg}"
3263- ) ;
3264- assert ! (
3265- msg. contains( "FilterExec" )
3266- || msg. contains( "FilterPushdown" )
3267- || msg. contains( "try_pushdown_filters" ) ,
3268- "error should point at the correct API path, got: {msg}"
3264+ msg. contains( "virtual column" ) && msg. contains( "pushdown" ) ,
3265+ "error should explain the virtual-column + pushdown context, \
3266+ got: {msg}"
32693267 ) ;
32703268 }
32713269
32723270 #[ tokio:: test]
32733271 async fn test_row_index_predicate_pushdown_virtual_only_errors ( ) {
3274- // Predicate referencing only a virtual column with pushdown_filters=true.
32753272 let store = Arc :: new ( InMemory :: new ( ) ) as Arc < dyn ObjectStore > ;
3276- let ( file_schema, data_size) =
3277- write_grouped_file ( & store, "pushdown_virtual_only.parquet" , 1 , 5 ) . await ;
3278-
3279- let rn_field = row_number_field ( "row_number" , false ) ;
3280- let table_schema = TableSchema :: from_file_schema ( Arc :: clone ( & file_schema) )
3281- . with_virtual_columns ( vec ! [ Arc :: clone( & rn_field) ] ) ;
3282- let projection =
3283- ProjectionExprs :: from_indices ( & [ 0 , 1 ] , table_schema. table_schema ( ) ) ;
3284-
32853273 let expr = col ( "row_number" ) . eq ( lit ( 2i64 ) ) ;
3286- let predicate = logical2physical ( & expr, table_schema. table_schema ( ) ) ;
3287-
3288- let morselizer = ParquetMorselizerBuilder :: new ( )
3289- . with_store ( Arc :: clone ( & store) )
3290- . with_table_schema ( table_schema)
3291- . with_projection ( projection)
3292- . with_predicate ( predicate)
3293- . with_pushdown_filters ( true )
3294- . build ( ) ;
3274+ let ( morselizer, file) = build_pushdown_morselizer (
3275+ & store,
3276+ "pushdown_virtual_only.parquet" ,
3277+ expr,
3278+ true ,
3279+ )
3280+ . await ;
32953281
3296- let file = PartitionedFile :: new (
3297- "pushdown_virtual_only.parquet" . to_string ( ) ,
3298- u64:: try_from ( data_size) . unwrap ( ) ,
3299- ) ;
33003282 let err = morselizer. plan_file ( file) . unwrap_err ( ) ;
33013283 assert ! ( err. to_string( ) . contains( "row_number" ) ) ;
33023284 }
33033285
33043286 #[ tokio:: test]
33053287 async fn test_row_index_predicate_allowed_when_pushdown_disabled ( ) {
3306- // Same predicate, but pushdown_filters=false. The predicate is only
3307- // used for stats pruning (which is a no-op for row_number) and must
3308- // NOT error.
3288+ // Guards the ` pushdown_filters=false` path: the predicate is only
3289+ // used for stats pruning (a no-op for row_number) and must not
3290+ // trip the pushdown-guard error.
33093291 let store = Arc :: new ( InMemory :: new ( ) ) as Arc < dyn ObjectStore > ;
3310- let ( file_schema, data_size) =
3311- write_grouped_file ( & store, "pushdown_off.parquet" , 1 , 5 ) . await ;
3312-
3313- let rn_field = row_number_field ( "row_number" , false ) ;
3314- let table_schema = TableSchema :: from_file_schema ( Arc :: clone ( & file_schema) )
3315- . with_virtual_columns ( vec ! [ Arc :: clone( & rn_field) ] ) ;
3316- let projection =
3317- ProjectionExprs :: from_indices ( & [ 0 , 1 ] , table_schema. table_schema ( ) ) ;
3318-
33193292 let expr = col ( "row_number" ) . eq ( lit ( 2i64 ) ) ;
3320- let predicate = logical2physical ( & expr, table_schema. table_schema ( ) ) ;
3321-
3322- let morselizer = ParquetMorselizerBuilder :: new ( )
3323- . with_store ( Arc :: clone ( & store) )
3324- . with_table_schema ( table_schema)
3325- . with_projection ( projection)
3326- . with_predicate ( predicate)
3327- . build ( ) ; // pushdown_filters defaults to false
3293+ let ( morselizer, file) =
3294+ build_pushdown_morselizer ( & store, "pushdown_off.parquet" , expr, false )
3295+ . await ;
33283296
3329- let file = PartitionedFile :: new (
3330- "pushdown_off.parquet" . to_string ( ) ,
3331- u64:: try_from ( data_size) . unwrap ( ) ,
3332- ) ;
3333- // Should open cleanly and return all rows (no row filter applied).
33343297 let stream = open_file ( & morselizer, file) . await . unwrap ( ) ;
33353298 let ( _batches, rows) = count_batches_and_rows ( stream) . await ;
33363299 assert_eq ! ( rows, 5 ) ;
0 commit comments