diff --git a/java/vortex-spark/common/src/test/java/dev/vortex/spark/VortexListElementPruningIoTest.java b/java/vortex-spark/common/src/test/java/dev/vortex/spark/VortexListElementPruningIoTest.java
new file mode 100644
index 00000000000..17e80b0b5e2
--- /dev/null
+++ b/java/vortex-spark/common/src/test/java/dev/vortex/spark/VortexListElementPruningIoTest.java
@@ -0,0 +1,295 @@
+// SPDX-License-Identifier: Apache-2.0
+// SPDX-FileCopyrightText: Copyright the Vortex contributors
+
+package dev.vortex.spark;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Locale;
+import java.util.function.Function;
+import java.util.stream.Stream;
+import org.apache.spark.sql.Column;
+import org.apache.spark.sql.Dataset;
+import org.apache.spark.sql.Row;
+import org.apache.spark.sql.SparkSession;
+import org.apache.spark.sql.functions;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.TestInstance;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+/**
+ * Tests that reading a leaf inside lists of structs nested in structs reads only that leaf from storage, through both
+ * Spark read paths, and returns what Parquet returns.
+ *
+ *
The table nests lists the way wide event schemas do, one to four lists deep under structs:
+ *
+ *
+ * id
+ * r: struct
+ * q: struct
+ * imp: array<struct
+ * id, w0..w7
+ * banner: struct
+ * w, h, b0..b7
+ * format: array<struct<w, h, f0..f7>>
+ * tags: array<string>
+ * seats: array<struct
+ * name, s0..s7
+ * deals: array<struct
+ * price, d0..d7
+ * segs: array<struct<v, g0..g7>>
+ *
+ *
+ * Every struct carries eight wide string siblings, so a read that pulls in a leaf's siblings shows up in the bytes
+ * read. Each list level has null lists, null elements and empty lists. The table is written with
+ * {@code vortex.listLayout}, without it, and as Parquet to compare against.
+ */
+@TestInstance(TestInstance.Lifecycle.PER_CLASS)
+public final class VortexListElementPruningIoTest {
+ private static final int ROWS = 8192;
+ private static final String PRUNING = "spark.sql.optimizer.nestedSchemaPruning.enabled";
+ private static final String USE_V1 = "spark.sql.sources.useV1SourceList";
+
+ private SparkSession spark;
+ private String listLayoutPath;
+ private String flatPath;
+ private String parquetPath;
+
+ @BeforeAll
+ public void setUp(@TempDir Path tempDir) {
+ spark = SparkSession.builder()
+ .appName("VortexListElementPruningIoTest")
+ .master("local[2]")
+ .config("spark.driver.host", "127.0.0.1")
+ .config("spark.sql.shuffle.partitions", "2")
+ .config("spark.sql.adaptive.enabled", "false")
+ .config(PRUNING, "true")
+ // Spark prunes below two arrays only through an index, which ANSI mode fails on an empty array.
+ .config("spark.sql.ansi.enabled", "false")
+ .config("spark.ui.enabled", "false")
+ .config(
+ "spark.hadoop.fs.file.impl",
+ VortexNestedSchemaPruningIoTest.CountingLocalFileSystem.class.getName())
+ .config("spark.hadoop.fs.file.impl.disable.cache", "true")
+ .getOrCreate();
+
+ Dataset rows = spark.range(0, ROWS, 1, 2)
+ .selectExpr("id", "named_struct('q', named_struct('imp', " + imps() + ")) AS r");
+ listLayoutPath = tempDir.resolve("list_layout").toString();
+ rows.write().format("vortex").option("vortex.listLayout", "true").save(listLayoutPath);
+ flatPath = tempDir.resolve("flat").toString();
+ rows.write().format("vortex").save(flatPath);
+ parquetPath = tempDir.resolve("parquet").toString();
+ rows.write().parquet(parquetPath);
+ }
+
+ @AfterAll
+ public void tearDown() {
+ if (spark != null) {
+ spark.stop();
+ SparkSession.clearActiveSession();
+ SparkSession.clearDefaultSession();
+ }
+ }
+
+ /** Eight wide string fields named {@code prefix0..7}, distinct per row and position. */
+ private static String wide(String prefix, String key) {
+ StringBuilder out = new StringBuilder();
+ for (int k = 0; k < 8; k++) {
+ out.append(String.format(
+ Locale.ROOT, ", '%s%d', sha2(concat_ws(':', '%s%d', %s), 256)", prefix, k, prefix, k, key));
+ }
+ return out.toString();
+ }
+
+ /**
+ * A list of {@code element} over {@code sequence(0, max - 1)}, null when {@code seed % nullEvery == 0}, with a
+ * length of {@code seed % (max + 1)}, so some lists are empty, and a null element when {@code (seed + i) % 7 == 0}.
+ */
+ private static String list(String var, String seed, int max, int nullEvery, String element) {
+ return String.format(
+ Locale.ROOT,
+ "CASE WHEN (%s) %% %d = 0 THEN NULL ELSE transform(filter(sequence(0, %d), %s -> %s < (%s) %% %d),"
+ + " %s -> CASE WHEN (%s + %s) %% 7 = 0 THEN NULL ELSE %s END) END",
+ seed,
+ nullEvery,
+ max - 1,
+ var,
+ var,
+ seed,
+ max + 1,
+ var,
+ seed,
+ var,
+ element);
+ }
+
+ private static String imps() {
+ String format = list(
+ "k",
+ "id + i + 3",
+ 3,
+ 5,
+ "named_struct('w', id + i * 10 + k, 'h', id + k" + wide("f", "id, i, k") + ")");
+ String banner = "CASE WHEN (id + i) % 6 = 0 THEN NULL ELSE named_struct('w', id * 3 + i, 'h', id + i"
+ + wide("b", "id, i") + ", 'format', " + format + ") END";
+ String tags = list("t", "id + i + 1", 4, 9, "concat('tag', id % 13, '-', t)");
+ String segs = list(
+ "g",
+ "id + i + c + d + 2",
+ 3,
+ 6,
+ "named_struct('v', id + i + c + d + g" + wide("g", "id, i, c, d, g") + ")");
+ String deals = list(
+ "d",
+ "id + i + c + 1",
+ 2,
+ 4,
+ "named_struct('price', id * 7 + i + c + d" + wide("d", "id, i, c, d") + ", 'segs', " + segs + ")");
+ String seats = list(
+ "c",
+ "id + i",
+ 3,
+ 8,
+ "named_struct('name', concat('seat', c)" + wide("s", "id, i, c") + ", 'deals', " + deals + ")");
+ String imp = "named_struct('id', id * 10 + i" + wide("w", "id, i") + ", 'banner', " + banner + ", 'tags', "
+ + tags + ", 'seats', " + seats + ")";
+ return list("i", "id", 4, 11, imp);
+ }
+
+ private static final Column IMP = functions.col("r.q.imp");
+
+ /**
+ * Leaves at one to four lists deep, each with the column read whole for comparison: the leaf's list, or the
+ * innermost list around it.
+ */
+ static Stream leaves() {
+ return Stream.of(
+ Arguments.of("r.q.imp.id (1 list)", IMP.getField("id"), IMP),
+ Arguments.of(
+ "r.q.imp.banner.w (1 list, struct below)",
+ IMP.getField("banner").getField("w"),
+ IMP),
+ Arguments.of(
+ "r.q.imp[0].banner.format.w (2 lists)",
+ IMP.getItem(0).getField("banner").getField("format").getField("w"),
+ IMP.getItem(0).getField("banner").getField("format")),
+ Arguments.of("r.q.imp.tags (list of strings in a list)", IMP.getField("tags"), IMP),
+ Arguments.of(
+ "r.q.imp[0].seats.deals[0].price (3 lists)",
+ IMP.getItem(0)
+ .getField("seats")
+ .getField("deals")
+ .getItem(0)
+ .getField("price"),
+ IMP.getItem(0).getField("seats")),
+ Arguments.of(
+ "r.q.imp[0].seats[0].deals[0].segs.v (4 lists)",
+ IMP.getItem(0)
+ .getField("seats")
+ .getItem(0)
+ .getField("deals")
+ .getItem(0)
+ .getField("segs")
+ .getField("v"),
+ IMP.getItem(0)
+ .getField("seats")
+ .getItem(0)
+ .getField("deals")
+ .getItem(0)
+ .getField("segs")));
+ }
+
+ static Stream leavesByPath() {
+ return leaves().flatMap(
+ a -> Stream.of(true, false).map(v1 -> Arguments.of(a.get()[0], a.get()[1], a.get()[2], v1)));
+ }
+
+ @ParameterizedTest(name = "{0}, V1={3}")
+ @MethodSource("leavesByPath")
+ public void leafInsideListsReadsOnlyThatLeaf(String name, Column leaf, Column whole, boolean v1) {
+ withReadPath(v1, () -> {
+ Dataset table = spark.read().format("vortex").load(listLayoutPath);
+ long leafBytes = bytesRead(table.select(leaf));
+ long wholeBytes = bytesRead(table.select(whole));
+ long flatLeaf =
+ bytesRead(spark.read().format("vortex").load(flatPath).select(leaf));
+ System.out.printf(
+ Locale.ROOT,
+ "bytes read, V1=%s, %s: list layout %d (around it %d), flat %d%n",
+ v1,
+ name,
+ leafBytes,
+ wholeBytes,
+ flatLeaf);
+ assertTrue(
+ leafBytes * 5 < wholeBytes,
+ name + " read " + leafBytes + " bytes; the list around it read " + wholeBytes);
+ assertMatchesParquet(t -> t.select(functions.col("id"), leaf));
+ });
+ }
+
+ @ParameterizedTest(name = "V1={0}")
+ @MethodSource("readPaths")
+ public void severalLeavesAndFiltersMatchParquet(boolean v1) {
+ withReadPath(v1, () -> {
+ assertMatchesParquet(t -> t.select(
+ functions.col("id"),
+ IMP.getField("id"),
+ IMP.getField("banner").getField("h"),
+ IMP.getItem(1).getField("seats").getField("name")));
+ assertMatchesParquet(t -> t.where(functions.size(IMP).gt(2))
+ .select(
+ functions.col("id"),
+ IMP.getItem(0).getField("banner").getField("format").getField("h")));
+ assertMatchesParquet(t -> t.where(IMP.isNull()).select(functions.col("id"), IMP.getField("id")));
+ assertMatchesParquet(t -> t.where(functions.array_contains(
+ IMP.getField("id"), functions.col("id").multiply(10)))
+ .select(functions.col("id"), IMP.getField("tags")));
+ });
+ }
+
+ static Stream readPaths() {
+ return Stream.of(true, false);
+ }
+
+ private void withReadPath(boolean v1, Runnable body) {
+ String previous = spark.conf().get(USE_V1, "");
+ spark.conf().set(USE_V1, v1 ? "vortex" : "");
+ try {
+ body.run();
+ } finally {
+ spark.conf().set(USE_V1, previous);
+ }
+ }
+
+ /** Asserts {@code query} returns Parquet's rows from both Vortex tables, with and without pruning. */
+ private void assertMatchesParquet(Function, Dataset> query) {
+ List expected =
+ query.apply(spark.read().parquet(parquetPath)).orderBy("id").collectAsList();
+ for (String path : List.of(listLayoutPath, flatPath)) {
+ Dataset read = query.apply(spark.read().format("vortex").load(path));
+ assertEquals(expected, read.orderBy("id").collectAsList(), path);
+ spark.conf().set(PRUNING, "false");
+ try {
+ assertEquals(expected, read.orderBy("id").collectAsList(), path + ", unpruned");
+ } finally {
+ spark.conf().set(PRUNING, "true");
+ }
+ }
+ }
+
+ /** Hashes every selected value so the whole result is decoded, and returns the bytes read from storage. */
+ private static long bytesRead(Dataset query) {
+ VortexNestedSchemaPruningIoTest.BYTES_READ.set(0);
+ query.selectExpr("bit_xor(xxhash64(*))").collectAsList();
+ return VortexNestedSchemaPruningIoTest.BYTES_READ.get();
+ }
+}
diff --git a/vortex-jni/goldenfiles/list_layout/event_shape.vortex b/vortex-jni/goldenfiles/list_layout/event_shape.vortex
new file mode 100644
index 00000000000..bb49deb7cde
Binary files /dev/null and b/vortex-jni/goldenfiles/list_layout/event_shape.vortex differ
diff --git a/vortex-jni/goldenfiles/list_layout/nullable_struct_elements.vortex b/vortex-jni/goldenfiles/list_layout/nullable_struct_elements.vortex
new file mode 100644
index 00000000000..9195dfd4248
Binary files /dev/null and b/vortex-jni/goldenfiles/list_layout/nullable_struct_elements.vortex differ
diff --git a/vortex-jni/goldenfiles/list_layout/zero_row_chunks.vortex b/vortex-jni/goldenfiles/list_layout/zero_row_chunks.vortex
new file mode 100644
index 00000000000..20d4b6bcd1f
Binary files /dev/null and b/vortex-jni/goldenfiles/list_layout/zero_row_chunks.vortex differ
diff --git a/vortex-jni/src/list_layout_golden.rs b/vortex-jni/src/list_layout_golden.rs
index 62921cb5fad..f38ba78d9b2 100644
--- a/vortex-jni/src/list_layout_golden.rs
+++ b/vortex-jni/src/list_layout_golden.rs
@@ -30,6 +30,8 @@ use vortex::array::arrays::ListArray;
use vortex::array::arrays::PrimitiveArray;
use vortex::array::arrays::StructArray;
use vortex::array::arrays::VarBinViewArray;
+use vortex::array::dtype::DType;
+use vortex::array::stream::ArrayStreamAdapter;
use vortex::array::stream::ArrayStreamExt;
use vortex::array::validity::Validity;
use vortex::buffer::Buffer;
@@ -88,6 +90,13 @@ enum Case {
InStruct,
/// Lists that are all empty.
AllEmpty,
+ /// A wide event schema's shape: a nullable struct holding nullable lists of nullable structs, which hold a nullable
+ /// struct, a list of strings and lists nested four deep.
+ EventShape,
+ /// Nullable lists of nullable structs.
+ NullableStructElements,
+ /// Nullable lists written as chunks with empty chunks before, between and after them.
+ ZeroRowChunks,
}
impl Case {
@@ -100,6 +109,9 @@ impl Case {
Case::Nested => "nested",
Case::InStruct => "in_struct",
Case::AllEmpty => "all_empty",
+ Case::EventShape => "event_shape",
+ Case::NullableStructElements => "nullable_struct_elements",
+ Case::ZeroRowChunks => "zero_row_chunks",
}
}
@@ -196,6 +208,34 @@ impl Case {
)?;
StructArray::from_fields(&[("l", l)])?.into_array()
}
+ Case::EventShape => event_shape(session, rows)?,
+ Case::NullableStructElements => {
+ let offsets = offsets(rows, |row| (row * 5 + 2) % 5);
+ let n = usize::try_from(last(&offsets))?;
+ let elements = StructArray::try_new(
+ ["a", "b"].into(),
+ [
+ PrimitiveArray::from_option_iter(
+ (0..n).map(|i| (i % 4 != 0).then_some(i as i64)),
+ )
+ .into_array(),
+ VarBinViewArray::from_iter_nullable_str(
+ (0..n).map(|i| (i % 6 != 0).then(|| format!("b{i}"))),
+ )
+ .into_array(),
+ ],
+ n,
+ Validity::from_iter((0..n).map(|i| i % 9 != 0)),
+ )?;
+ let validity = Validity::from_iter((0..rows).map(|row| row % 7 != 0));
+ list(session, elements.into_array(), offsets, validity)?
+ }
+ Case::ZeroRowChunks => {
+ let offsets = offsets(rows, |row| (row * 3 + 1) % 4);
+ let elements = Buffer::from_iter((0..last(&offsets)).map(|i| i64::from(i) * 7));
+ let validity = Validity::from_iter((0..rows).map(|row| row % 10 != 0));
+ list(session, elements.into_array(), offsets, validity)?
+ }
Case::AllEmpty => list(
session,
Buffer::::empty().into_array(),
@@ -203,15 +243,178 @@ impl Case {
Validity::NonNullable,
)?,
};
- let name = if matches!(self, Case::InStruct) {
- "s"
- } else {
- "l"
+ let name = match self {
+ Case::InStruct => "s",
+ Case::EventShape => "r",
+ _ => "l",
};
Ok(StructArray::from_fields(&[("id", id), (name, lists)])?.into_array())
}
}
+/// Nullable structs of `fields`, each struct valid unless `null(row)`.
+fn nullable_struct(
+ fields: &[(&str, ArrayRef)],
+ len: usize,
+ null: impl Fn(usize) -> bool,
+) -> VortexResult {
+ Ok(StructArray::try_new(
+ fields.iter().map(|(n, _)| *n).collect::>().into(),
+ fields.iter().map(|(_, a)| a.clone()),
+ len,
+ Validity::from_iter((0..len).map(|row| !null(row))),
+ )?
+ .into_array())
+}
+
+fn nullable_i64(len: usize, f: impl Fn(usize) -> Option) -> ArrayRef {
+ PrimitiveArray::from_option_iter((0..len).map(f)).into_array()
+}
+
+/// A sibling string field, so a leaf's siblings are real columns. Values repeat, to keep the file small.
+fn wide_strings(len: usize, tag: &str) -> ArrayRef {
+ VarBinViewArray::from_iter_nullable_str(
+ (0..len).map(|i| (i % 5 != 0).then(|| format!("{tag}{}", i % 50))),
+ )
+ .into_array()
+}
+
+/// A nullable list over `elements`, each of `rows` lists holding `len(row)` and null when `null(row)`.
+fn nullable_list(
+ session: &VortexSession,
+ rows: usize,
+ elements: impl FnOnce(usize) -> VortexResult,
+ len: impl Fn(usize) -> usize,
+ null: impl Fn(usize) -> bool,
+) -> VortexResult {
+ let offsets = offsets(rows, |row| if null(row) { 0 } else { len(row) });
+ let n = usize::try_from(last(&offsets))?;
+ list(
+ session,
+ elements(n)?,
+ offsets,
+ Validity::from_iter((0..rows).map(|row| !null(row))),
+ )
+}
+
+/// [`Case::EventShape`]'s `r`: `{q: {imp: [{id, w, banner: {w, h, b, format: [{w, h, f}]}, tags: [utf8],
+/// seats: [{name, s, deals: [{price, d, segs: [{v, g}]}]}]}]}}`, nullable at every level.
+fn event_shape(session: &VortexSession, rows: usize) -> VortexResult {
+ let imp = nullable_list(
+ session,
+ rows,
+ |n| {
+ let format = nullable_list(
+ session,
+ n,
+ |m| {
+ nullable_struct(
+ &[
+ ("w", nullable_i64(m, |i| Some(i as i64 * 3))),
+ ("h", nullable_i64(m, |i| (i % 4 != 0).then_some(i as i64))),
+ ("f", wide_strings(m, "f")),
+ ],
+ m,
+ |i| i % 8 == 0,
+ )
+ },
+ |i| i % 3,
+ |i| i % 5 == 0,
+ )?;
+ let banner = nullable_struct(
+ &[
+ ("w", nullable_i64(n, |i| Some(i as i64 * 2))),
+ ("h", nullable_i64(n, |i| (i % 3 != 0).then_some(i as i64))),
+ ("b", wide_strings(n, "b")),
+ ("format", format),
+ ],
+ n,
+ |i| i % 6 == 0,
+ )?;
+ let tags = nullable_list(
+ session,
+ n,
+ |m| {
+ Ok(VarBinViewArray::from_iter_nullable_str(
+ (0..m).map(|i| (i % 7 != 0).then(|| format!("t{}", i % 13))),
+ )
+ .into_array())
+ },
+ |i| i % 4,
+ |i| i % 9 == 0,
+ )?;
+ let seats = nullable_list(
+ session,
+ n,
+ |m| {
+ let deals = nullable_list(
+ session,
+ m,
+ |k| {
+ let segs = nullable_list(
+ session,
+ k,
+ |j| {
+ nullable_struct(
+ &[
+ ("v", nullable_i64(j, |i| Some(i as i64))),
+ ("g", wide_strings(j, "g")),
+ ],
+ j,
+ |i| i % 10 == 0,
+ )
+ },
+ |i| i % 3,
+ |i| i % 6 == 0,
+ )?;
+ nullable_struct(
+ &[
+ (
+ "price",
+ nullable_i64(k, |i| (i % 5 != 0).then_some(i as i64 * 11)),
+ ),
+ ("d", wide_strings(k, "d")),
+ ("segs", segs),
+ ],
+ k,
+ |i| i % 7 == 0,
+ )
+ },
+ |i| i % 3,
+ |i| i % 4 == 0,
+ )?;
+ nullable_struct(
+ &[
+ ("name", wide_strings(m, "seat")),
+ ("s", wide_strings(m, "s")),
+ ("deals", deals),
+ ],
+ m,
+ |i| i % 8 == 0,
+ )
+ },
+ |i| i % 4,
+ |i| i % 8 == 0,
+ )?;
+ nullable_struct(
+ &[
+ ("id", nullable_i64(n, |i| Some(i as i64 * 10))),
+ ("w", wide_strings(n, "w")),
+ ("banner", banner),
+ ("tags", tags),
+ ("seats", seats),
+ ],
+ n,
+ |i| i % 7 == 0,
+ )
+ },
+ |row| row % 5,
+ |row| row % 11 == 0,
+ )?;
+ let q = nullable_struct(&[("imp", imp)], rows, |row| row % 13 == 0)?;
+ nullable_struct(&[("q", q)], rows, |row| row % 17 == 0)
+}
+
/// Offsets of `rows` lists, the list of row `row` holding `len(row)` elements.
fn offsets(rows: usize, len: impl Fn(usize) -> usize) -> Buffer {
let mut end = 0u32;
@@ -244,16 +447,39 @@ fn list(
/// Writes `array` as a `vortex.listLayout` write does.
fn write(array: ArrayRef) -> VortexResult {
+ write_chunks(array.dtype().clone(), vec![array])
+}
+
+/// Writes `chunks` as one stream, as a `vortex.listLayout` write of several batches does.
+fn write_chunks(dtype: DType, chunks: Vec) -> VortexResult {
let session = write_session(&new_session(), true)?;
let mut bytes = ByteBufferMut::empty();
- RUNTIME.block_on(
- session
- .write_options()
- .write(&mut bytes, array.to_array_stream()),
- )?;
+ let stream = ArrayStreamAdapter::new(dtype, futures::stream::iter(chunks.into_iter().map(Ok)));
+ RUNTIME.block_on(session.write_options().write(&mut bytes, stream))?;
Ok(bytes.freeze())
}
+/// Writes `case` the way its golden file was written: in one stream, and for [`Case::ZeroRowChunks`] as several
+/// populated chunks with empty ones before, between and after them.
+fn write_case(session: &VortexSession, case: Case) -> VortexResult {
+ let array = case.array(session)?;
+ if !matches!(case, Case::ZeroRowChunks) {
+ return write(array);
+ }
+ let empty = || array.slice(0..0);
+ let chunks = vec![
+ empty()?,
+ array.slice(0..7_000)?,
+ empty()?,
+ empty()?,
+ array.slice(7_000..15_000)?,
+ empty()?,
+ array.slice(15_000..ROWS)?,
+ empty()?,
+ ];
+ write_chunks(array.dtype().clone(), chunks)
+}
+
/// Opens `bytes` as the JNI reader does.
fn open(session: &VortexSession, bytes: ByteBuffer) -> VortexResult {
file_open_options(session.open_options()).open_buffer(bytes)
@@ -336,7 +562,7 @@ fn filtered(
array.filter(Mask::from_iter(keep.into_bit_buffer().iter()))
}
-const CASES: [Case; 7] = [
+const CASES: [Case; 10] = [
Case::Primitive,
Case::Nullable,
Case::Utf8,
@@ -344,6 +570,9 @@ const CASES: [Case; 7] = [
Case::Nested,
Case::InStruct,
Case::AllEmpty,
+ Case::EventShape,
+ Case::NullableStructElements,
+ Case::ZeroRowChunks,
];
#[template]
@@ -355,6 +584,9 @@ const CASES: [Case; 7] = [
#[case::nested(Case::Nested)]
#[case::in_struct(Case::InStruct)]
#[case::all_empty(Case::AllEmpty)]
+#[case::event_shape(Case::EventShape)]
+#[case::nullable_struct_elements(Case::NullableStructElements)]
+#[case::zero_row_chunks(Case::ZeroRowChunks)]
fn cases(#[case] case: Case) {}
#[apply(cases)]
@@ -373,7 +605,7 @@ fn golden_file_has_list_layouts(#[case] case: Case) -> VortexResult<()> {
fn writer_writes_the_golden_list_layouts(#[case] case: Case) -> VortexResult<()> {
let session = new_session();
let golden = list_layouts(golden(&session, case)?.footer().layout())?;
- let written = open(&session, write(case.array(&session)?)?)?;
+ let written = open(&session, write_case(&session, case)?)?;
assert_eq!(
list_layouts(written.footer().layout())?,
golden,
@@ -441,6 +673,28 @@ fn golden_file_reads_row_indices(#[case] case: Case) -> VortexResult<()> {
#[case::element_field(Case::StructElements, list_transform(col("l"), element("a")))]
#[case::nullable_element_field(Case::StructElements, list_transform(col("l"), element("b")))]
#[case::utf8_elements(Case::Utf8, col("l"))]
+#[case::nullable_struct_element_field(
+ Case::NullableStructElements,
+ list_transform(col("l"), element("b"))
+)]
+#[case::zero_row_chunks(Case::ZeroRowChunks, col("l"))]
+#[case::event_leaf_one_list(Case::EventShape, list_transform(imp(), element("id")))]
+#[case::event_leaf_below_a_struct(Case::EventShape, list_transform(imp(), path(&["banner", "w"])))]
+#[case::event_strings_in_a_list(Case::EventShape, list_transform(imp(), element("tags")))]
+#[case::event_leaf_two_lists(
+ Case::EventShape,
+ list_transform(
+ imp(),
+ lambda(
+ "x",
+ list_transform(get_item("format", get_item("banner", var("x"))), element("w"))
+ )
+ )
+)]
+#[case::event_leaf_four_lists(
+ Case::EventShape,
+ list_transform(imp(), lambda("x", seats_deals_segs_v(var("x"))))
+)]
fn golden_file_reads_projections(
#[case] case: Case,
#[case] projection: Expression,
@@ -470,6 +724,30 @@ fn golden_file_reads_filters(#[case] case: Case, #[case] filter: Expression) ->
Ok(())
}
+/// `r.q.imp` of [`Case::EventShape`].
+fn imp() -> Expression {
+ get_item("imp", get_item("q", col("r")))
+}
+
+/// `x -> x.....`, for `list_transform`.
+fn path(fields: &[&str]) -> Lambda {
+ let body = fields.iter().fold(var("x"), |e, f| get_item(*f, e));
+ Lambda::new(["x"], body).vortex_expect("lambda binds one parameter")
+}
+
+/// ` -> body`, for `list_transform`.
+fn lambda(name: &str, body: Expression) -> Lambda {
+ Lambda::new([name], body).vortex_expect("lambda binds one parameter")
+}
+
+/// `imp.seats[].deals[].segs[].v` below one `imp` element: three more lists, four in all.
+fn seats_deals_segs_v(imp: Expression) -> Expression {
+ let segs = |deal: Expression| list_transform(get_item("segs", deal), element("v"));
+ let deals =
+ |seat: Expression| list_transform(get_item("deals", seat), lambda("d", segs(var("d"))));
+ list_transform(get_item("seats", imp), lambda("s", deals(var("s"))))
+}
+
/// `x -> x.`, for `list_transform`.
fn element(field: &str) -> Lambda {
Lambda::new(["x"], get_item(field, var("x"))).vortex_expect("lambda binds one parameter")
@@ -487,7 +765,7 @@ fn write_missing_golden_files() -> VortexResult<()> {
continue;
}
fs::create_dir_all(path.parent().vortex_expect("golden files have a directory"))?;
- fs::write(&path, write(case.array(&session)?)?.as_slice())?;
+ fs::write(&path, write_case(&session, case)?.as_slice())?;
}
Ok(())
}