Skip to content

Commit de12c75

Browse files
committed
Move nested stats to a new top level field. Legacy stats still populated
by default, but added a new writer option to omit them.
1 parent ad70a8d commit de12c75

10 files changed

Lines changed: 487 additions & 83 deletions

File tree

‎vortex-datafusion/src/persistent/format.rs‎

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ use object_store::ObjectStore;
4747
use vortex::VortexSessionDefault;
4848
use vortex::array::memory::MemorySessionExt;
4949
use vortex::dtype::DType;
50+
use vortex::dtype::FieldPath;
5051
use vortex::dtype::Nullability;
5152
use vortex::dtype::PType;
5253
use vortex::error::VortexExpect;
@@ -598,7 +599,7 @@ impl FileFormat for VortexFormat {
598599
})
599600
});
600601

601-
let (dtype, file_stats, row_count) = match cached_metadata {
602+
let (_dtype, file_stats, row_count) = match cached_metadata {
602603
Some(metadata) => metadata,
603604
None => {
604605
// Not entry - open the file
@@ -635,10 +636,6 @@ impl FileFormat for VortexFormat {
635636
}
636637
};
637638

638-
let struct_dtype = dtype
639-
.as_struct_fields_opt()
640-
.vortex_expect("dtype is not a struct");
641-
642639
// Evaluate the statistics for each column that we are able to return to DataFusion.
643640
let Some(file_stats) = file_stats else {
644641
// If the file has no column stats, the best we can do is return a row count.
@@ -661,12 +658,13 @@ impl FileFormat for VortexFormat {
661658
for field in table_schema.fields().iter() {
662659
// If the column does not exist, continue. This can happen if the schema has evolved
663660
// but we have not yet updated the Vortex file.
664-
let Some(col_idx) = struct_dtype.find(field.name()) else {
661+
let Some((stats_set, stats_dtype)) =
662+
file_stats.get_by_path(&FieldPath::from_name(field.name().as_str()))
663+
else {
665664
// The default sets all statistics to `Precision<Absent>`.
666665
column_statistics.push(ColumnStatistics::default());
667666
continue;
668667
};
669-
let (stats_set, stats_dtype) = file_stats.get(col_idx);
670668

671669
// Update the total size in bytes.
672670
let column_size =

‎vortex-duckdb/src/copy.rs‎

Lines changed: 51 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ use vortex::array::ArrayRef;
1616
use vortex::array::stream::ArrayStreamAdapter;
1717
use vortex::dtype::DType;
1818
use vortex::dtype::FieldName;
19+
use vortex::dtype::FieldPath;
1920
use vortex::dtype::Nullability::NonNullable;
2021
use vortex::dtype::Nullability::Nullable;
2122
use vortex::dtype::StructFields;
@@ -211,8 +212,9 @@ pub(crate) fn written_column_stats(
211212
fn 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.
231233
fn 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
}

‎vortex-duckdb/src/file_reader.rs‎

Lines changed: 53 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ use url::Url;
1111
use vortex::array::VortexSessionExecute as _;
1212
use vortex::array::arrays::struct_::StructArrayExt as _;
1313
use vortex::dtype::DType;
14+
use vortex::dtype::FieldPath;
1415
use vortex::error::VortexExpect;
1516
use vortex::error::VortexResult;
1617
use vortex::error::vortex_panic;
@@ -274,16 +275,12 @@ pub fn reader_get_statistics(
274275
.as_any()
275276
.downcast_ref::<FileStatsLayoutReader>()?;
276277

277-
let DType::Struct(fields, _) = &file.reader.dtype() else {
278-
return None;
279-
};
280-
let index = fields.find(column)?;
281-
let stats_sets = reader.file_stats().stats_sets();
278+
let (stats, dtype) = reader
279+
.file_stats()
280+
.get_by_path(&FieldPath::from_name(column))?;
282281

283-
let dtype = fields.field_by_index(index)?;
284-
285-
let stats = ColumnStatisticsAggregate::new(stats_sets.get(index)?);
286-
match ColumnStatistics::try_from(stats, dtype) {
282+
let stats = ColumnStatisticsAggregate::new(stats);
283+
match ColumnStatistics::try_from(stats, dtype.clone()) {
287284
Ok(stats) => Some(stats),
288285
Err(e) => vortex_panic!(e),
289286
}
@@ -328,12 +325,56 @@ pub fn footer_get_statistics(footer: &Footer, index: usize) -> Option<ColumnStat
328325
let DType::Struct(fields, _) = footer.dtype() else {
329326
return None;
330327
};
328+
let name = fields.names().get(index)?;
331329
let stats = footer.statistics()?;
332-
let dtype = fields.field_by_index(index)?;
333-
let stats = stats.stats_sets().get(index)?;
330+
let (stats, dtype) = stats.get_by_path(&FieldPath::from_name(name.clone()))?;
334331
let stats = ColumnStatisticsAggregate::new(stats);
335-
match ColumnStatistics::try_from(stats, dtype) {
332+
match ColumnStatistics::try_from(stats, dtype.clone()) {
336333
Ok(stats) => Some(stats),
337334
Err(e) => vortex_panic!(e),
338335
}
339336
}
337+
338+
#[cfg(test)]
339+
mod tests {
340+
use vortex::array::IntoArray;
341+
use vortex::array::arrays::StructArray;
342+
use vortex::array::stats::PRUNING_STATS;
343+
use vortex::array::validity::Validity;
344+
use vortex::buffer::ByteBufferMut;
345+
use vortex::buffer::buffer;
346+
use vortex::file::WriteOptionsSessionExt;
347+
348+
use super::*;
349+
use crate::RUNTIME;
350+
use crate::SESSION;
351+
352+
#[test]
353+
fn footer_get_statistics_resolves_by_name_past_a_nullable_struct_field() {
354+
// Regression test: a nullable top-level struct field ("s") contributes both a `s.b` entry
355+
// and a trailing entry for its own null count to the nested (post-order) stats layout, so
356+
// that layout has one more entry than there are top-level fields. Resolving a later
357+
// top-level column ("c", index 1) by flat position into that layout would silently return
358+
// `s`'s own null-count-only entry (which has no Min) instead of `c`'s stats.
359+
let footer = RUNTIME.block_on(async {
360+
let b = buffer![10i32, 20, 30].into_array();
361+
let inner = StructArray::new(["b"].into(), [b], 3, Validity::AllValid).into_array();
362+
let c = buffer![7u32, 8, 9].into_array();
363+
let outer = StructArray::new(["s", "c"].into(), [inner, c], 3, Validity::NonNullable)
364+
.into_array();
365+
366+
let mut buf = ByteBufferMut::empty();
367+
let mut writer = SESSION
368+
.write_options()
369+
.with_file_statistics(PRUNING_STATS.to_vec())
370+
.writer(&mut buf, outer.dtype().clone());
371+
writer.push(outer).await.unwrap();
372+
writer.finish().await.unwrap().footer().clone()
373+
});
374+
375+
let stats =
376+
footer_get_statistics(&footer, 1).expect("stats for top-level column 1 (\"c\")");
377+
assert!(stats.min.is_some());
378+
assert!(stats.max.is_some());
379+
}
380+
}

‎vortex-file/flatbuffers/vortex-file/footer.fbs‎

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -56,17 +56,22 @@ table PostscriptSegment {
5656
// [footer]
5757
/// The `FileStatistics` object contains file-level statistics for the Vortex file.
5858
table FileStatistics {
59-
/// Statistics for each field in the root schema. If the root schema is not a struct, there will
60-
/// be a single entry in this array.
59+
/// Statistics for each top-level field in the root schema, in schema order. If the root schema
60+
/// is not a struct, there will be a single entry in this array. Does not recurse into nested
61+
/// struct fields; see `nested_field_stats` for that.
6162
///
62-
/// When `is_nested` is true, entries follow a post-order walk of the root `DType` tree: each
63-
/// leaf field gets an entry, and each nullable struct additionally gets a trailing null-count
64-
/// entry inserted after its children's entries.
63+
/// Kept in this legacy shape for backward compatibility with readers that predate
64+
/// `nested_field_stats`, which strictly validate this array's length against the number of
65+
/// top-level struct fields.
6566
field_stats: [ArrayStats];
66-
/// Whether `field_stats` follows the post-order nested-struct layout (true) or the legacy
67-
/// top-level-fields-only layout (false). Defaults to false for backward compatibility with
68-
/// files written before nested field stats were supported.
69-
is_nested: bool = false;
67+
/// Statistics following a post-order walk of the root `DType` tree: each leaf field gets an
68+
/// entry, and each nullable struct additionally gets a trailing null-count entry inserted
69+
/// after its children's entries.
70+
///
71+
/// Readers that know about this field should prefer it over `field_stats` when present. It is
72+
/// absent for files written before nested file stats were supported, and readers that don't
73+
/// know about it fall back to `field_stats`'s legacy top-level-fields-only layout.
74+
nested_field_stats: [ArrayStats];
7075
}
7176

7277
/// The `Registry` object stores dictionary-encoded configuration for segments,

0 commit comments

Comments
 (0)