Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
fddc8d4
first pass
May 22, 2026
4f96af7
fix
May 22, 2026
c8656d7
second claude pass
May 22, 2026
e7dfcf0
small fixes
May 26, 2026
928b47a
fix
May 26, 2026
d892a26
fix
May 26, 2026
181d44a
clean mod
mhk197 May 27, 2026
0470d62
fix writer
mhk197 May 27, 2026
29d59ed
fix writer
mhk197 May 27, 2026
c020401
fix
mhk197 May 27, 2026
b5648ec
improve projection eval
mhk197 May 27, 2026
8c36fea
projection evaluation
mhk197 May 27, 2026
8845ebc
tests
mhk197 May 27, 2026
a0d7255
tests
mhk197 May 27, 2026
ba3f6f9
fix test
mhk197 May 27, 2026
399fc99
skip pruning eval
mhk197 May 27, 2026
3e15d07
few more tests
mhk197 May 27, 2026
e1d18ef
lint fix
mhk197 May 28, 2026
1905ac8
fix test
mhk197 May 28, 2026
1cb0527
quick fix
mhk197 May 28, 2026
c25d565
add anylist matcher
mhk197 May 28, 2026
4d17031
read validity with all-true mask, not caller's mask
mhk197 May 28, 2026
8bb210a
narrow elements io for sparse mask instead of defering filtering
mhk197 May 28, 2026
ee061b9
shortcut on whole-chunk unmasked reads
mhk197 May 28, 2026
b2b8175
add required fallback to ListLayoutStrategy for non-list input
mhk197 Jun 17, 2026
f375c14
fmt
mhk197 Jun 17, 2026
4848681
cleanup
mhk197 Jun 17, 2026
9d78821
fix rebase conflicts
mhk197 Jun 17, 2026
4f1b34c
use ListLayoutStrategy as default leaf under unstable_encodings
mhk197 Jun 17, 2026
78bf480
recurse into nested lists
mhk197 Jun 17, 2026
358fcb2
fix: update ListLayout::build to use LayoutBuildContext after trait c…
mhk197 Jun 22, 2026
ad6774b
comments
mhk197 Jun 22, 2026
b4e8def
fix
mhk197 Jun 22, 2026
63a4b71
clean up writer
mhk197 Jun 23, 2026
a52edca
Improve list reader
mhk197 Jun 23, 2026
6f62108
Fix list docs
mhk197 Jun 23, 2026
ea190cb
Add list filter evaluation
mhk197 Jun 23, 2026
9b1a689
Read list lengths from list layout offsets
mhk197 Jun 29, 2026
11116dd
Move list expression planning to expr module
mhk197 Jul 1, 2026
6fe666d
Fix list layout checks after rebase
mhk197 Jul 1, 2026
afa5361
Remove list projection path labels
mhk197 Jul 1, 2026
69db497
Refine list expression child classification
mhk197 Jul 1, 2026
c1fa258
remove stale comment
mhk197 Jul 6, 2026
5e99ce0
simplify projection
mhk197 Jul 6, 2026
86d3068
make list layout the default
mhk197 Jul 7, 2026
1b55509
list: top-level streaming decomposition behind VORTEX_EXPERIMENTAL_LI…
mhk197 Jul 9, 2026
02ca612
fix: drop redundant explicit rustdoc link target
mhk197 Jul 10, 2026
7ba2ebf
list: simplify list layout reader/writer
mhk197 Jul 10, 2026
e4349a9
fix: bound partial list element reads
mhk197 Jul 13, 2026
35d8da1
remove bad pruning
mhk197 Jul 13, 2026
3292598
unchecked list construction
mhk197 Jul 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions vortex-file/src/strategy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ use vortex_layout::layouts::flat::writer::FlatLayoutStrategy;
use vortex_layout::layouts::repartition::RepartitionStrategy;
use vortex_layout::layouts::repartition::RepartitionWriterOptions;
use vortex_layout::layouts::table::TableStrategy;
use vortex_layout::layouts::table::use_experimental_list_layout;
use vortex_layout::layouts::zoned::writer::ZonedLayoutOptions;
use vortex_layout::layouts::zoned::writer::ZonedStrategy;
#[cfg(feature = "unstable_encodings")]
Expand Down Expand Up @@ -157,6 +158,10 @@ pub struct WriteStrategyBuilder {
allow_encodings: Option<HashSet<ArrayId>>,
flat_strategy: Option<Arc<dyn LayoutStrategy>>,
probe_compressor: Option<Arc<dyn CompressorPlugin>>,
/// Whether to write list fields using [`ListLayoutStrategy`].
///
/// [`ListLayoutStrategy`]: vortex_layout::layouts::list::writer::ListLayoutStrategy
use_list_layout: bool,
}

impl Default for WriteStrategyBuilder {
Expand All @@ -170,6 +175,7 @@ impl Default for WriteStrategyBuilder {
allow_encodings: Some(ALLOWED_ENCODINGS.clone()),
flat_strategy: None,
probe_compressor: None,
use_list_layout: use_experimental_list_layout(),
}
}
}
Expand All @@ -184,6 +190,14 @@ impl WriteStrategyBuilder {
self
}

/// Enable writing list fields with [`ListLayoutStrategy`].
///
/// [`ListLayoutStrategy`]: vortex_layout::layouts::list::writer::ListLayoutStrategy
pub fn with_list_layout(mut self) -> Self {
self.use_list_layout = true;
self
}

/// Override the write layout for a specific field somewhere in the nested schema tree.
///
/// The field path is matched after the root struct is split into columns. This is useful when a
Expand Down Expand Up @@ -335,8 +349,13 @@ impl WriteStrategyBuilder {
let validity_strategy = CollectStrategy::new(compress_then_flat);

// Take any field overrides from the builder and apply them to the final strategy.
let table_strategy = TableStrategy::new(Arc::new(validity_strategy), Arc::new(repartition))
.with_field_writers(self.field_writers);
let mut table_strategy =
TableStrategy::new(Arc::new(validity_strategy), Arc::new(repartition))
.with_field_writers(self.field_writers);

if self.use_list_layout {
table_strategy = table_strategy.with_list_layout();
}

Arc::new(table_strategy)
}
Expand Down
70 changes: 70 additions & 0 deletions vortex-file/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ use vortex_array::ArrayRef;
use vortex_array::IntoArray;
use vortex_array::VortexSessionExecute;
use vortex_array::array_session;
use vortex_array::arrays::BoolArray;
use vortex_array::arrays::ChunkedArray;
use vortex_array::arrays::ConstantArray;
use vortex_array::arrays::DecimalArray;
Expand Down Expand Up @@ -1671,6 +1672,75 @@ async fn test_writer_with_complex_types() -> VortexResult<()> {
Ok(())
}

/// Write `array` with list decomposition forced on (through the full compress/zone pipeline) and
/// read the whole thing back.
async fn write_read_roundtrip(array: ArrayRef) -> VortexResult<ArrayRef> {
let strategy = crate::strategy::WriteStrategyBuilder::default()
.with_list_layout()
.build();
let mut buf = ByteBufferMut::empty();
SESSION
.write_options()
.with_strategy(strategy)
.write(&mut buf, array.to_array_stream())
.await?;
SESSION
.open_options()
.open_buffer(buf)?
.scan()?
.into_array_stream()?
.read_all()
.await
}

/// A `list<list<i32>>` column round-trips through the `TableStrategy` dispatcher, exercising list
/// decomposition recursing into itself (the outer list's `elements` are themselves lists).
#[tokio::test]
#[cfg_attr(miri, ignore)]
async fn nested_list_of_list_roundtrip() -> VortexResult<()> {
let inner = ListArray::try_new(
buffer![1i32, 2, 3, 4, 5, 6].into_array(),
buffer![0u32, 2, 5, 5, 6].into_array(),
Validity::NonNullable,
)?
.into_array();
let outer = ListArray::try_new(
inner,
buffer![0u32, 2, 4].into_array(),
Validity::NonNullable,
)?
.into_array();
let st = StructArray::from_fields(&[("nested", outer)])?.into_array();

let result = write_read_roundtrip(st.clone()).await?;
assert_arrays_eq!(result, st, &mut SESSION.create_execution_ctx());
Ok(())
}

/// A `struct<{ items: list<struct<{a,b}>>? }>` column round-trips, exercising list decomposition
/// recursing into struct decomposition (list `elements` are structs) plus a nullable list validity
/// child.
#[tokio::test]
#[cfg_attr(miri, ignore)]
async fn nested_struct_list_struct_roundtrip() -> VortexResult<()> {
let inner_struct = StructArray::from_fields(&[
("a", buffer![1i32, 2, 3, 4, 5].into_array()),
("b", buffer![10i32, 20, 30, 40, 50].into_array()),
])?
.into_array();
let items = ListArray::try_new(
inner_struct,
buffer![0u32, 2, 5, 5].into_array(),
Validity::Array(BoolArray::from_iter([true, false, true]).into_array()),
)?
.into_array();
let st = StructArray::from_fields(&[("items", items)])?.into_array();

let result = write_read_roundtrip(st.clone()).await?;
assert_arrays_eq!(result, st, &mut SESSION.create_execution_ctx());
Ok(())
}

#[tokio::test]
async fn test_writer_with_statistics() -> VortexResult<()> {
let array = StructArray::from_fields(&[("numbers", buffer![1u32, 2, 3, 4, 5].into_array())])?
Expand Down
2 changes: 1 addition & 1 deletion vortex-layout/src/layouts/chunked/mod.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright the Vortex contributors

mod reader;
pub(crate) mod reader;
pub mod writer;

use std::sync::Arc;
Expand Down
150 changes: 150 additions & 0 deletions vortex-layout/src/layouts/list/expr.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright the Vortex contributors

use vortex_array::expr::Expression;
use vortex_array::expr::is_root;
use vortex_array::expr::not;
use vortex_array::expr::root;
use vortex_array::scalar_fn::fns::is_not_null::IsNotNull;
use vortex_array::scalar_fn::fns::is_null::IsNull;
use vortex_array::scalar_fn::fns::list_length::ListLength;
use vortex_error::VortexResult;

/// The minimal set of list children an expression needs for evaluation.
///
/// For example:
/// - `is_null(root())` only needs the validity child.
/// - `list_length(root())` only needs the offsets and validity children.
/// - `root()` needs elements, offsets, and validity children.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub(super) enum ListChildrenNeeded {
/// Only the validity child is needed (`is_null` / `is_not_null`).
Validity,
/// Only the offsets and validity children are needed (`list_length`).
OffsetsAndValidity,
/// All children are needed.
All,
}

/// The minimal set of list children needed to evaluate `expr`, where `root()` is a field with list dtype.
pub(super) fn get_necessary_list_children(expr: &Expression) -> ListChildrenNeeded {
if is_null_root(expr) {
return ListChildrenNeeded::Validity;
}

if is_list_length_root(expr) {
return ListChildrenNeeded::OffsetsAndValidity;
}

if is_root(expr) {
return ListChildrenNeeded::All;
}

// Otherwise the requirement is the max over the operands. Childless expressions that never
// touch the list, such as literals, fall back to the cheapest usable child.
expr.children()
.iter()
.map(get_necessary_list_children)
.max()
.unwrap_or(ListChildrenNeeded::Validity)
}

fn is_null_root(expr: &Expression) -> bool {
(expr.is::<IsNull>() || expr.is::<IsNotNull>())
&& expr.children().len() == 1
&& is_root(expr.child(0))
}

fn is_list_length_root(expr: &Expression) -> bool {
expr.is::<ListLength>() && expr.children().len() == 1 && is_root(expr.child(0))
}

/// Rewrite a validity-class expression so it can be evaluated against the list's validity bool
/// array (`true` == valid row): `is_not_null(root())` becomes `root()` and `is_null(root())`
/// becomes `not(root())`. All other nodes are rebuilt with rewritten children.
pub(super) fn rewrite_validity_expr(expr: &Expression) -> VortexResult<Expression> {
if expr.is::<IsNotNull>() && expr.children().len() == 1 && is_root(expr.child(0)) {
return Ok(root());
}
if expr.is::<IsNull>() && expr.children().len() == 1 && is_root(expr.child(0)) {
return Ok(not(root()));
}
let children = expr
.children()
.iter()
.map(rewrite_validity_expr)
.collect::<VortexResult<Vec<_>>>()?;
expr.clone().with_children(children)
}

/// Rewrite an offsets-class expression so it can be evaluated against an array of list lengths.
/// `list_length(root())` becomes `root()`. Other references to `root()` are left intact: for
/// offsets-class expressions they can only be validity checks, and the lengths array carries the
/// same validity as the original list.
pub(super) fn rewrite_offsets_expr(expr: &Expression) -> VortexResult<Expression> {
if is_list_length_root(expr) {
return Ok(root());
}

let children = expr
.children()
.iter()
.map(rewrite_offsets_expr)
.collect::<VortexResult<Vec<_>>>()?;
expr.clone().with_children(children)
}

#[cfg(test)]
mod tests {
use rstest::rstest;
use vortex_array::dtype::DType;
use vortex_array::dtype::Nullability;
use vortex_array::dtype::PType;
use vortex_array::expr::cast;
use vortex_array::expr::eq;
use vortex_array::expr::gt;
use vortex_array::expr::is_not_null;
use vortex_array::expr::is_null;
use vortex_array::expr::list_length;
use vortex_array::expr::lit;
use vortex_array::expr::not;
use vortex_array::expr::root;

use super::*;

/// `get_necessary_list_children` keys off the deepest list child an expression touches; `All`
/// is the always-correct default for anything not specifically recognized.
#[rstest]
// `is_null` / `is_not_null` of the list itself need only validity.
#[case::is_null(is_null(root()), ListChildrenNeeded::Validity)]
#[case::is_not_null(is_not_null(root()), ListChildrenNeeded::Validity)]
// Compound over validity-only operands stays validity.
#[case::not_is_null(not(is_null(root())), ListChildrenNeeded::Validity)]
// A list-independent (constant) expression falls to the cheapest usable child.
#[case::constant(lit(5), ListChildrenNeeded::Validity)]
// `list_length(root())` needs offsets and validity, but not elements.
#[case::list_length(list_length(root()), ListChildrenNeeded::OffsetsAndValidity)]
// Compound over offsets-only operands stays offsets.
#[case::list_length_filter(
gt(list_length(root()), lit(1u64)),
ListChildrenNeeded::OffsetsAndValidity
)]
#[case::cast_list_length(
cast(
list_length(root()),
DType::Primitive(PType::I64, Nullability::Nullable),
),
ListChildrenNeeded::OffsetsAndValidity
)]
// A bare list reference needs the elements.
#[case::bare_root(root(), ListChildrenNeeded::All)]
// Any other fn over the list needs the elements.
#[case::not_root(not(root()), ListChildrenNeeded::All)]
// `is_null` only short-circuits to validity when its argument is the list itself.
#[case::is_null_of_derived(is_null(not(root())), ListChildrenNeeded::All)]
// Max over operands: validity + elements => elements.
#[case::validity_and_elements(eq(is_null(root()), root()), ListChildrenNeeded::All)]
fn classify_expr_class(#[case] expr: Expression, #[case] expected: ListChildrenNeeded) {
assert_eq!(get_necessary_list_children(&expr), expected);
}
}
Loading
Loading