@@ -26,6 +26,8 @@ use vortex::error::vortex_bail;
2626use vortex:: error:: vortex_err;
2727use vortex:: expr:: stats:: Precision ;
2828use vortex:: expr:: stats:: Stat ;
29+ use vortex:: file:: CompressedFieldSizes ;
30+ use vortex:: file:: FileStatistics ;
2931use vortex:: file:: WriteOptionsSessionExt ;
3032use vortex:: file:: WriteSummary ;
3133use vortex:: file:: multi:: parse_uri_or_path;
@@ -55,11 +57,13 @@ pub struct CopyFunctionBind {
5557}
5658assert_impl_all ! ( CopyFunctionBind : Send , Clone ) ;
5759
58- /// The per-column compressed sizes are computed once here rather than per column, since DuckDB
59- /// queries statistics one column at a time.
60+ /// The compressed field sizes and the statistics columns are computed once here rather than per
61+ /// column, since DuckDB queries statistics one column at a time.
6062struct FinishedWrite {
6163 summary : WriteSummary ,
62- column_sizes : Vec < u64 > ,
64+ column_sizes : Option < CompressedFieldSizes > ,
65+ /// Indices into the footer's [`FileStatistics`] of the entries reported as statistics columns.
66+ stats_columns : Vec < usize > ,
6367}
6468
6569/// Write to a file has two phases, writing data chunks and then closing the file.
@@ -160,12 +164,18 @@ pub fn copy_to_finalize(init_global: &mut CopyFunctionGlobal) -> VortexResult<()
160164 . take ( )
161165 . vortex_expect ( "no file to close" ) ;
162166 // Keep the write summary (footer + size) so DuckLake can read per-file statistics back
163- // without re-opening the file. Compute the per-column compressed sizes once, up front.
167+ // without re-opening the file. Compute the compressed field sizes once, up front.
164168 let summary = task. await ?;
165- let column_sizes = summary. compressed_column_sizes ( ) . unwrap_or_default ( ) ;
169+ let column_sizes = summary. footer ( ) . compressed_field_sizes ( ) . ok ( ) ;
170+ let stats_columns = summary
171+ . footer ( )
172+ . statistics ( )
173+ . map ( leaf_stats_columns)
174+ . unwrap_or_default ( ) ;
166175 * init_global. finished . lock ( ) = Some ( FinishedWrite {
167176 summary,
168177 column_sizes,
178+ stats_columns,
169179 } ) ;
170180 Ok ( ( ) )
171181 } )
@@ -182,6 +192,8 @@ pub(crate) struct WrittenFileStats {
182192/// Per-column statistics of the written Vortex file. `min`/`max` are DuckDB values converted from
183193/// the Vortex scalar; every field is optional and omitted when the statistic is not available.
184194pub ( crate ) struct WrittenColumnStats {
195+ /// The column's path in DuckLake's key format (see [`ducklake_column_key`]).
196+ pub column_key : String ,
185197 pub min : Option < Value > ,
186198 pub max : Option < Value > ,
187199 pub null_count : Option < u64 > ,
@@ -193,7 +205,11 @@ pub(crate) struct WrittenColumnStats {
193205/// Read file-level statistics back from the finished write. `None` before finalize.
194206pub ( crate ) fn written_file_stats ( global : & CopyFunctionGlobal ) -> Option < WrittenFileStats > {
195207 let guard = global. finished . lock ( ) ;
196- Some ( file_stats_from_summary ( & guard. as_ref ( ) ?. summary ) )
208+ let finished = guard. as_ref ( ) ?;
209+ Some ( file_stats_from_summary (
210+ & finished. summary ,
211+ finished. stats_columns . len ( ) ,
212+ ) )
197213}
198214
199215/// Read per-column statistics for `column_index` from the finished write. `Ok(None)` if the file is
@@ -206,15 +222,45 @@ pub(crate) fn written_column_stats(
206222 let Some ( finished) = guard. as_ref ( ) else {
207223 return Ok ( None ) ;
208224 } ;
209- column_stats_from_summary ( & finished. summary , column_index, & finished. column_sizes ) . map ( Some )
225+ column_stats_from_summary (
226+ & finished. summary ,
227+ & finished. stats_columns ,
228+ column_index,
229+ finished. column_sizes . as_ref ( ) ,
230+ )
231+ . map ( Some )
210232}
211233
212- fn file_stats_from_summary ( summary : & WriteSummary ) -> WrittenFileStats {
213- let num_columns = summary
214- . footer ( )
215- . dtype ( )
216- . as_struct_fields_opt ( )
217- . map_or ( 1 , StructFields :: nfields) ;
234+ /// The indices of the leaf entries of `file_stats`, which are the columns statistics are reported
235+ /// for. Like parquet, which reports one entry per leaf column writer, struct columns get no entry of
236+ /// their own; nested fields are reported at their full path. Lists and maps are opaque leaves of the
237+ /// statistics layout, so they are reported at their own path rather than per element.
238+ fn leaf_stats_columns ( file_stats : & FileStatistics ) -> Vec < usize > {
239+ file_stats
240+ . dtypes ( )
241+ . iter ( )
242+ . enumerate ( )
243+ . filter ( |( _, dtype) | !dtype. is_struct ( ) )
244+ . map ( |( index, _) | index)
245+ . collect ( )
246+ }
247+
248+ /// DuckLake keys column statistics by a quoted, dot-separated path (see
249+ /// `DuckLakeUtil::ParseQuotedList`). This matches the parquet writer, which quotes each name with
250+ /// `KeywordHelper::WriteQuoted(name, '"')`, escaping embedded quotes by doubling them. A list
251+ /// element is named `element`, as in parquet's list encoding.
252+ fn ducklake_column_key ( path : & FieldPath ) -> String {
253+ path. parts ( )
254+ . iter ( )
255+ . map ( |part| {
256+ let name = part. as_name ( ) . unwrap_or ( "element" ) ;
257+ format ! ( "\" {}\" " , name. replace( '"' , "\" \" " ) )
258+ } )
259+ . collect :: < Vec < _ > > ( )
260+ . join ( "." )
261+ }
262+
263+ fn file_stats_from_summary ( summary : & WriteSummary , num_columns : usize ) -> WrittenFileStats {
218264 WrittenFileStats {
219265 row_count : summary. row_count ( ) ,
220266 file_size_bytes : summary. size ( ) ,
@@ -224,45 +270,38 @@ fn file_stats_from_summary(summary: &WriteSummary) -> WrittenFileStats {
224270 }
225271}
226272
227- /// Per-column statistics from a finished write's summary and its precomputed compressed sizes
228- /// (`column_sizes`, indexed by top-level field position).
229- ///
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.
273+ /// Statistics for the `column_index`-th statistics column of a finished write, where
274+ /// `stats_columns` are the [leaf entries][leaf_stats_columns] of its footer statistics and
275+ /// `column_sizes` its compressed field sizes.
233276fn column_stats_from_summary (
234277 summary : & WriteSummary ,
278+ stats_columns : & [ usize ] ,
235279 column_index : usize ,
236- column_sizes : & [ u64 ] ,
280+ column_sizes : Option < & CompressedFieldSizes > ,
237281) -> VortexResult < WrittenColumnStats > {
238282 let file_stats = summary
239283 . footer ( )
240284 . statistics ( )
241285 . ok_or_else ( || vortex_err ! ( "written file has no statistics" ) ) ?;
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 ( || {
286+ let stats_index = * stats_columns. get ( column_index) . ok_or_else ( || {
248287 vortex_err ! (
249- "column index {column_index} out of range for {} top-level fields " ,
250- struct_fields . nfields ( )
288+ "column index {column_index} out of range for {} statistics columns " ,
289+ stats_columns . len ( )
251290 )
252291 } ) ?;
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}" ) ) ?;
292+ let ( stats, dtype) = file_stats. get ( stats_index) ;
293+ let path = & file_stats. paths ( ) [ stats_index] ;
256294
257295 Ok ( WrittenColumnStats {
296+ column_key : ducklake_column_key ( path) ,
258297 min : exact_scalar_to_duckdb ( stats. get ( Stat :: Min ) , dtype) ?,
259298 max : exact_scalar_to_duckdb ( stats. get ( Stat :: Max ) , dtype) ?,
260299 null_count : exact_u64 ( stats. get ( Stat :: NullCount ) ) ,
261300 // NaNCount is exact only for float columns, so this is emitted just for them (as in parquet).
262301 has_nan : exact_u64 ( stats. get ( Stat :: NaNCount ) ) . map ( |count| count > 0 ) ,
263302 num_values : summary. row_count ( ) ,
264303 // On-disk compressed size; excludes bytes not attributable to a column (e.g. struct validity).
265- column_size_bytes : column_sizes. get ( column_index ) . copied ( ) ,
304+ column_size_bytes : column_sizes. and_then ( |sizes| sizes . get ( path ) ) ,
266305 } )
267306}
268307
@@ -376,44 +415,71 @@ mod tests {
376415 } )
377416 }
378417
418+ /// Statistics for the `column_index`-th statistics column of `summary`, resolved the same way
419+ /// `copy_to_finalize` and [`written_column_stats`] do.
420+ fn column_stats (
421+ summary : & WriteSummary ,
422+ column_index : usize ,
423+ ) -> VortexResult < WrittenColumnStats > {
424+ let stats_columns = summary
425+ . footer ( )
426+ . statistics ( )
427+ . map ( leaf_stats_columns)
428+ . unwrap_or_default ( ) ;
429+ let column_sizes = summary. footer ( ) . compressed_field_sizes ( ) ?;
430+ column_stats_from_summary ( summary, & stats_columns, column_index, Some ( & column_sizes) )
431+ }
432+
379433 #[ test]
380434 fn column_stats_out_of_range_is_an_error ( ) {
381435 let summary = write_summary ( pruning_aggregate_fns ( ) ) ;
382- assert ! ( column_stats_from_summary ( & summary, 0 , & [ ] ) . is_ok( ) ) ;
383- assert ! ( column_stats_from_summary ( & summary, 1 , & [ ] ) . is_err( ) ) ;
436+ assert ! ( column_stats ( & summary, 0 ) . is_ok( ) ) ;
437+ assert ! ( column_stats ( & summary, 1 ) . is_err( ) ) ;
384438 }
385439
386440 #[ test]
387441 fn column_stats_without_file_statistics_is_an_error ( ) {
388442 let summary = write_summary ( vec ! [ ] ) ;
389- assert ! ( column_stats_from_summary ( & summary, 0 , & [ ] ) . is_err( ) ) ;
443+ assert ! ( column_stats ( & summary, 0 ) . is_err( ) ) ;
390444 }
391445
392446 #[ test]
393- fn column_stats_resolves_by_name_past_a_nullable_struct_column ( ) {
394- // Regression test: a nullable top-level struct field ("s") contributes both a `s.b` entry
395- // and a trailing entry for its own null count to the nested (post-order) stats layout, so
396- // that layout has one more entry than there are top-level fields. Resolving a later
397- // top-level column ("c", index 1) by flat position into that layout would silently return
398- // `s`'s own null-count-only entry (which has no Min) instead of `c`'s stats.
447+ fn column_stats_reports_nested_leaves_by_quoted_path ( ) -> VortexResult < ( ) > {
448+ // A nullable struct `s` contributes `s.b` and a trailing own entry to the post-order stats
449+ // layout; only the leaves are statistics columns, keyed by their full quoted path.
399450 let summary = RUNTIME . block_on ( async {
400451 let b = buffer ! [ 10i32 , 20 , 30 ] . into_array ( ) ;
401452 let inner = StructArray :: new ( [ "b" ] . into ( ) , [ b] , 3 , Validity :: AllValid ) . into_array ( ) ;
402453 let c = buffer ! [ 7u32 , 8 , 9 ] . into_array ( ) ;
403- let outer = StructArray :: new ( [ "s" , "c" ] . into ( ) , [ inner, c] , 3 , Validity :: NonNullable )
404- . into_array ( ) ;
454+ let outer =
455+ StructArray :: new ( [ "s" , "c\" q" ] . into ( ) , [ inner, c] , 3 , Validity :: NonNullable )
456+ . into_array ( ) ;
405457
406458 let mut buf = ByteBufferMut :: empty ( ) ;
407459 let mut writer = SESSION
408460 . write_options ( )
409461 . with_file_statistics ( pruning_aggregate_fns ( ) )
410462 . writer ( & mut buf, outer. dtype ( ) . clone ( ) ) ;
411- writer. push ( outer) . await . unwrap ( ) ;
412- writer. finish ( ) . await . unwrap ( )
413- } ) ;
414-
415- let stats = column_stats_from_summary ( & summary, 1 , & [ ] ) . unwrap ( ) ;
416- assert ! ( stats. min. is_some( ) ) ;
417- assert ! ( stats. max. is_some( ) ) ;
463+ writer. push ( outer) . await ?;
464+ writer. finish ( ) . await
465+ } ) ?;
466+
467+ let file_stats = summary
468+ . footer ( )
469+ . statistics ( )
470+ . ok_or_else ( || vortex_err ! ( "no statistics" ) ) ?;
471+ assert_eq ! ( leaf_stats_columns( file_stats) . len( ) , 2 ) ;
472+ let nested = column_stats ( & summary, 0 ) ?;
473+ assert_eq ! ( nested. column_key, "\" s\" .\" b\" " ) ;
474+ assert ! ( nested. min. is_some( ) ) ;
475+ assert ! ( nested. column_size_bytes. is_some_and( |size| size > 0 ) ) ;
476+
477+ let top_level = column_stats ( & summary, 1 ) ?;
478+ assert_eq ! ( top_level. column_key, "\" c\" \" q\" " ) ;
479+ assert ! ( top_level. min. is_some( ) ) ;
480+ assert ! ( top_level. max. is_some( ) ) ;
481+
482+ assert ! ( column_stats( & summary, 2 ) . is_err( ) ) ;
483+ Ok ( ( ) )
418484 }
419485}
0 commit comments