@@ -87,6 +87,7 @@ use super::ParquetFileMetrics;
8787use super :: supported_predicates:: supports_list_predicates;
8888use crate :: projection_read_plan:: {
8989 ParquetReadPlan , PushdownChecker , PushdownColumns , assemble_read_plan,
90+ build_read_plan_with_cast_clipping,
9091} ;
9192
9293/// A "compiled" predicate passed to `ParquetRecordBatchStream` to perform
@@ -281,12 +282,26 @@ pub(crate) fn build_parquet_read_plan(
281282 return Ok ( None ) ;
282283 } ;
283284
284- let ( read_plan, leaf_indices) = assemble_read_plan (
285- & required_columns. required_columns ,
286- & required_columns. struct_field_accesses ,
287- file_schema,
288- schema_descr,
289- ) ;
285+ // A retained Struct cast names the fields its conversion touches, so the
286+ // read is clipped to those leaves rather than decoding the whole root.
287+ // A cast whose target covers every leaf, or that cannot be clipped safely,
288+ // falls back to a full read of that root inside the helper.
289+ let ( read_plan, leaf_indices) = if required_columns. cast_accesses . is_empty ( ) {
290+ assemble_read_plan (
291+ & required_columns. required_columns ,
292+ & required_columns. struct_field_accesses ,
293+ file_schema,
294+ schema_descr,
295+ )
296+ } else {
297+ build_read_plan_with_cast_clipping (
298+ file_schema,
299+ schema_descr,
300+ & required_columns. required_columns ,
301+ & required_columns. struct_field_accesses ,
302+ & required_columns. cast_accesses ,
303+ )
304+ } ;
290305
291306 let required_bytes = size_of_columns ( & leaf_indices, metadata) ?;
292307
@@ -1237,11 +1252,12 @@ mod test {
12371252 fn get_field_filter_candidate_has_correct_leaf_indices ( ) {
12381253 use arrow:: array:: { Int32Array , StringArray , StructArray } ;
12391254
1240- // Schema: id (Int32), s (Struct{value: Int32, label: Utf8})
1241- // Parquet leaves: id=0, s.value=1, s.label=2
1255+ // Schema: id (Int32), s (Struct{value: Int32, label: Utf8, unused: Utf8 })
1256+ // Parquet leaves: id=0, s.value=1, s.label=2, s.unused=3
12421257 let struct_fields: Fields = vec ! [
12431258 Arc :: new( Field :: new( "value" , DataType :: Int32 , false ) ) ,
12441259 Arc :: new( Field :: new( "label" , DataType :: Utf8 , false ) ) ,
1260+ Arc :: new( Field :: new( "unused" , DataType :: Utf8 , false ) ) ,
12451261 ]
12461262 . into ( ) ;
12471263 let schema = Arc :: new ( Schema :: new ( vec ! [
@@ -1258,6 +1274,9 @@ mod test {
12581274 vec![
12591275 Arc :: new( Int32Array :: from( vec![ 10 , 20 , 30 ] ) ) as _,
12601276 Arc :: new( StringArray :: from( vec![ "a" , "b" , "c" ] ) ) as _,
1277+ Arc :: new( StringArray :: from( vec![
1278+ "unused-a" , "unused-b" , "unused-c" ,
1279+ ] ) ) as _,
12611280 ] ,
12621281 None ,
12631282 ) ) ,
@@ -1293,16 +1312,20 @@ mod test {
12931312 . expect ( "get_field filter on struct should be pushable" ) ;
12941313
12951314 // The filter accesses only s.value, so only Parquet leaf 1 is needed.
1296- // Leaf 2 (s.label) is not read, reducing unnecessary I/O.
1315+ // Neither sibling is read, reducing unnecessary I/O.
12971316 let expected_mask =
12981317 ProjectionMask :: leaves ( metadata. file_metadata ( ) . schema_descr ( ) , [ 1 ] ) ;
12991318 assert_eq ! (
13001319 candidate. read_plan. projection_mask, expected_mask,
13011320 "projection_mask should select only the accessed struct field leaf"
13021321 ) ;
13031322
1304- // Schema adaptation can leave a Struct cast intact. Its runtime filter
1305- // must read every sibling, while planning still rejects explicit casts.
1323+ // Schema adaptation retains Struct ancestors for some decimal conversions
1324+ // so an all-null parent can skip child conversion. Runtime filters must
1325+ // preserve every conversion named by the retained cast target.
1326+ // This target includes `label`, so its conversion must still run even
1327+ // though get_field selects only `value`. Planning keeps a residual filter
1328+ // for explicit Struct casts.
13061329 let cast_type = DataType :: Struct (
13071330 vec ! [
13081331 Field :: new( "value" , DataType :: Int32 , false ) ,
@@ -1325,12 +1348,29 @@ mod test {
13251348 . build ( & metadata)
13261349 . expect ( "building cast candidate" )
13271350 . expect ( "an adapted struct cast must remain evaluable" ) ;
1351+ // Clip the unused sibling, but preserve the failing `label` conversion.
13281352 let expected_mask =
1329- ProjectionMask :: roots ( metadata. file_metadata ( ) . schema_descr ( ) , [ 1 ] ) ;
1353+ ProjectionMask :: leaves ( metadata. file_metadata ( ) . schema_descr ( ) , [ 1 , 2 ] ) ;
13301354 assert_eq ! ( candidate. read_plan. projection_mask, expected_mask) ;
1355+ let DataType :: Struct ( physical_fields) = file_schema. field ( 1 ) . data_type ( ) else {
1356+ unreachable ! ( "s is a struct" )
1357+ } ;
1358+ let clipped_field =
1359+ file_schema
1360+ . field ( 1 )
1361+ . clone ( )
1362+ . with_data_type ( DataType :: Struct (
1363+ physical_fields. iter ( ) . take ( 2 ) . cloned ( ) . collect ( ) ,
1364+ ) ) ;
13311365 assert_eq ! (
13321366 candidate. read_plan. projected_schema. as_ref( ) ,
1333- & file_schema. project( & [ 1 ] ) . unwrap( )
1367+ & Schema :: new( vec![ clipped_field] )
1368+ ) ;
1369+ assert_eq ! (
1370+ candidate. required_bytes,
1371+ ( metadata. row_group( 0 ) . column( 1 ) . compressed_size( )
1372+ + metadata. row_group( 0 ) . column( 2 ) . compressed_size( ) ) as usize ,
1373+ "filter cost must count only the leaves the cast target reads"
13341374 ) ;
13351375
13361376 // A simultaneous direct access must not prune siblings that the cast
@@ -1362,6 +1402,54 @@ mod test {
13621402 . unwrap ( ) ;
13631403 let error = row_filter. evaluate ( batch) . unwrap_err ( ) . to_string ( ) ;
13641404 datafusion_common:: assert_contains!( error, "While casting struct field 'label'" ) ;
1405+
1406+ // A retained cast whose target names only the selected field — the
1407+ // shape `retain_field_path` produces for an evolved decimal — clips the
1408+ // read to that field's leaf instead of decoding the whole root.
1409+ let narrow_cast_type =
1410+ DataType :: Struct ( vec ! [ Field :: new( "value" , DataType :: Int32 , false ) ] . into ( ) ) ;
1411+ let narrow_field = get_field ( ) . call ( vec ! [
1412+ datafusion_expr:: cast( col( "s" ) , narrow_cast_type. clone( ) ) ,
1413+ lit( "value" ) ,
1414+ ] ) ;
1415+ let narrow_predicate = logical2physical ( & narrow_field. gt ( lit ( 5 ) ) , & file_schema) ;
1416+ let candidate =
1417+ FilterCandidateBuilder :: new ( narrow_predicate, Arc :: clone ( & file_schema) )
1418+ . build ( & metadata)
1419+ . expect ( "building narrow cast candidate" )
1420+ . expect ( "a clipped struct cast must remain evaluable" ) ;
1421+ assert_eq ! (
1422+ candidate. read_plan. projection_mask,
1423+ ProjectionMask :: leaves( metadata. file_metadata( ) . schema_descr( ) , [ 1 ] ) ,
1424+ "the read must be clipped to the leaf the cast target names"
1425+ ) ;
1426+ assert_eq ! ( candidate. read_plan. projected_schema. fields( ) . len( ) , 1 ) ;
1427+ assert_eq ! (
1428+ candidate. read_plan. projected_schema. field( 0 ) . data_type( ) ,
1429+ & narrow_cast_type,
1430+ "sibling leaves must be pruned from the filter schema"
1431+ ) ;
1432+
1433+ // The clipped schema must still evaluate: every row has value > 5.
1434+ let mut row_filter = DatafusionArrowPredicate :: try_new (
1435+ candidate,
1436+ Count :: new ( ) ,
1437+ Count :: new ( ) ,
1438+ Time :: new ( ) ,
1439+ )
1440+ . unwrap ( ) ;
1441+ let batch = ParquetRecordBatchReaderBuilder :: try_new ( file. reopen ( ) . unwrap ( ) )
1442+ . unwrap ( )
1443+ . with_projection ( row_filter. projection ( ) . clone ( ) )
1444+ . build ( )
1445+ . unwrap ( )
1446+ . next ( )
1447+ . unwrap ( )
1448+ . unwrap ( ) ;
1449+ assert_eq ! (
1450+ row_filter. evaluate( batch) . unwrap( ) ,
1451+ BooleanArray :: from( vec![ true , true , true ] )
1452+ ) ;
13651453 }
13661454
13671455 /// Deeply nested get_field: get_field(struct_col, 'outer', 'inner') where the
0 commit comments