@@ -16,6 +16,7 @@ use vortex::array::ArrayRef;
1616use vortex:: array:: stream:: ArrayStreamAdapter ;
1717use vortex:: dtype:: DType ;
1818use vortex:: dtype:: FieldName ;
19+ use vortex:: dtype:: FieldPath ;
1920use vortex:: dtype:: Nullability :: NonNullable ;
2021use vortex:: dtype:: Nullability :: Nullable ;
2122use vortex:: dtype:: StructFields ;
@@ -211,8 +212,9 @@ pub(crate) fn written_column_stats(
211212fn file_stats_from_summary ( summary : & WriteSummary ) -> WrittenFileStats {
212213 let num_columns = summary
213214 . footer ( )
214- . statistics ( )
215- . map_or ( 0 , |s| s. stats_sets ( ) . len ( ) ) ;
215+ . dtype ( )
216+ . as_struct_fields_opt ( )
217+ . map_or ( 1 , StructFields :: nfields) ;
216218 WrittenFileStats {
217219 row_count : summary. row_count ( ) ,
218220 file_size_bytes : summary. size ( ) ,
@@ -223,11 +225,11 @@ fn file_stats_from_summary(summary: &WriteSummary) -> WrittenFileStats {
223225}
224226
225227/// Per-column statistics from a finished write's summary and its precomputed compressed sizes
226- /// (`column_sizes`, indexed the same as the footer's stats sets ).
228+ /// (`column_sizes`, indexed by top-level field position ).
227229///
228- /// Only top-level columns are covered: the footer exposes one statistics set per top-level field ,
229- /// so nested struct/list leaf columns are not reported (parquet, by contrast, recurses to leaf
230- /// paths). Flat tables - the common DuckLake case - are fully covered.
230+ /// Only top-level columns are covered: nested struct/list leaf columns are not reported (parquet ,
231+ /// by contrast, recurses to leaf paths). Flat tables - the common DuckLake case - are fully
232+ /// covered.
231233fn column_stats_from_summary (
232234 summary : & WriteSummary ,
233235 column_index : usize ,
@@ -237,15 +239,20 @@ fn column_stats_from_summary(
237239 . footer ( )
238240 . statistics ( )
239241 . ok_or_else ( || vortex_err ! ( "written file has no statistics" ) ) ?;
240- let stats_sets = file_stats. stats_sets ( ) ;
241- if column_index >= stats_sets. len ( ) {
242- vortex_bail ! (
243- "column index {column_index} out of range for {} statistics sets" ,
244- stats_sets. len( )
245- ) ;
246- }
247- let stats = & stats_sets[ column_index] ;
248- let dtype = & file_stats. dtypes ( ) [ column_index] ;
242+ let struct_fields = summary
243+ . footer ( )
244+ . dtype ( )
245+ . as_struct_fields_opt ( )
246+ . ok_or_else ( || vortex_err ! ( "written file dtype is not a struct" ) ) ?;
247+ let name = struct_fields. names ( ) . get ( column_index) . ok_or_else ( || {
248+ vortex_err ! (
249+ "column index {column_index} out of range for {} top-level fields" ,
250+ struct_fields. nfields( )
251+ )
252+ } ) ?;
253+ let ( stats, dtype) = file_stats
254+ . get_by_path ( & FieldPath :: from_name ( name. clone ( ) ) )
255+ . ok_or_else ( || vortex_err ! ( "no statistics for column {name}" ) ) ?;
249256
250257 Ok ( WrittenColumnStats {
251258 min : exact_scalar_to_duckdb ( stats. get ( Stat :: Min ) , dtype) ?,
@@ -333,6 +340,7 @@ mod tests {
333340 use vortex:: array:: IntoArray ;
334341 use vortex:: array:: arrays:: StructArray ;
335342 use vortex:: array:: stats:: PRUNING_STATS ;
343+ use vortex:: array:: validity:: Validity ;
336344 use vortex:: buffer:: ByteBufferMut ;
337345 use vortex:: buffer:: buffer;
338346
@@ -367,4 +375,32 @@ mod tests {
367375 let summary = write_summary ( vec ! [ ] ) ;
368376 assert ! ( column_stats_from_summary( & summary, 0 , & [ ] ) . is_err( ) ) ;
369377 }
378+
379+ #[ test]
380+ fn column_stats_resolves_by_name_past_a_nullable_struct_column ( ) {
381+ // Regression test: a nullable top-level struct field ("s") contributes both a `s.b` entry
382+ // and a trailing entry for its own null count to the nested (post-order) stats layout, so
383+ // that layout has one more entry than there are top-level fields. Resolving a later
384+ // top-level column ("c", index 1) by flat position into that layout would silently return
385+ // `s`'s own null-count-only entry (which has no Min) instead of `c`'s stats.
386+ let summary = RUNTIME . block_on ( async {
387+ let b = buffer ! [ 10i32 , 20 , 30 ] . into_array ( ) ;
388+ let inner = StructArray :: new ( [ "b" ] . into ( ) , [ b] , 3 , Validity :: AllValid ) . into_array ( ) ;
389+ let c = buffer ! [ 7u32 , 8 , 9 ] . into_array ( ) ;
390+ let outer = StructArray :: new ( [ "s" , "c" ] . into ( ) , [ inner, c] , 3 , Validity :: NonNullable )
391+ . into_array ( ) ;
392+
393+ let mut buf = ByteBufferMut :: empty ( ) ;
394+ let mut writer = SESSION
395+ . write_options ( )
396+ . with_file_statistics ( PRUNING_STATS . to_vec ( ) )
397+ . writer ( & mut buf, outer. dtype ( ) . clone ( ) ) ;
398+ writer. push ( outer) . await . unwrap ( ) ;
399+ writer. finish ( ) . await . unwrap ( )
400+ } ) ;
401+
402+ let stats = column_stats_from_summary ( & summary, 1 , & [ ] ) . unwrap ( ) ;
403+ assert ! ( stats. min. is_some( ) ) ;
404+ assert ! ( stats. max. is_some( ) ) ;
405+ }
370406}
0 commit comments