This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/datafusion.git
The following commit(s) were added to refs/heads/main by this push:
new bf1c17ae6b feat: union nested Parquet leaves across projection
accesses (#24130)
bf1c17ae6b is described below
commit bf1c17ae6b41a0aa8fe95a7b20273aefb47d3b10
Author: Goutam Adwant <[email protected]>
AuthorDate: Sun Aug 16 01:13:03 2026 +0000
feat: union nested Parquet leaves across projection accesses (#24130)
## Which issue does this PR close?
- Closes #24121.
## Rationale for this change
When one projected nested column is consumed through multiple narrowing
casts, or through both a narrowing cast and `get_field`, the Parquet
read plan currently falls back to reading the entire root column. The
leaves required by those consumers can be combined, so reading the full
root performs unnecessary I/O.
## What changes are included in this PR?
- Accumulate and deduplicate the leaf offsets required by every
narrowing cast on a root column.
- Add leaves required by `get_field` accesses to the same union.
- Derive the Arrow type emitted by the reader from the merged leaf set
while retaining a full-read fallback for unsupported partial wrapper
shapes.
- Add coverage for disjoint, overlapping, and repeated casts, cast plus
`get_field`, and casts that diverge below `List<Struct>`.
## Are these changes tested?
Yes. The following checks pass:
- `cargo fmt --all -- --check`
- `cargo test -p datafusion-datasource-parquet --lib`
- `cargo test --profile=ci --test sqllogictests --
parquet_nested_schema_pruning.slt`
- `cargo clippy -p datafusion-datasource-parquet --all-targets
--all-features -- -D warnings`
The SQL logic test also verifies that the merged projections return the
expected values and scan fewer bytes than a full-root read.
## Are there any user-facing changes?
No API or SQL behavior changes. Queries with multiple nested accesses to
the same Parquet root can read fewer leaf columns.
---
.../src/nested_schema_pruning.rs | 109 +++++++++++
.../datasource-parquet/src/projection_read_plan.rs | 213 ++++++++++++++-------
.../test_files/parquet_nested_schema_pruning.slt | 44 ++++-
3 files changed, 287 insertions(+), 79 deletions(-)
diff --git a/datafusion/datasource-parquet/src/nested_schema_pruning.rs
b/datafusion/datasource-parquet/src/nested_schema_pruning.rs
index 9768282c3b..9d1534eff6 100644
--- a/datafusion/datasource-parquet/src/nested_schema_pruning.rs
+++ b/datafusion/datasource-parquet/src/nested_schema_pruning.rs
@@ -128,6 +128,35 @@ pub(crate) fn clip_for_cast(
Some((kept, pruned_type))
}
+/// Rebuilds `physical` for a sorted, deduplicated subset of its leaf offsets.
+///
+/// This is the second half of merging several nested accesses to one root:
+/// callers union the offsets required by each access, then use this function
+/// to derive the Arrow type the Parquet reader emits for that union. Partial
+/// projection is supported through the same struct, list, and large-list
+/// shapes as [`clip_for_cast`]. Any partial selection below another wrapper
+/// returns `None`, preserving the total-fallback property of cast clipping.
+pub(crate) fn type_for_leaf_subset(
+ physical: &DataType,
+ kept: &[usize],
+) -> Option<DataType> {
+ let total = count_leaves(physical);
+ if kept.is_empty()
+ || kept.len() >= total
+ || kept.windows(2).any(|pair| pair[0] >= pair[1])
+ || kept.last().is_some_and(|last| *last >= total)
+ {
+ return None;
+ }
+
+ let mut next_leaf = 0;
+ let projected = project_type_for_leaves(physical, kept, &mut next_leaf);
+ if projected.is_ok() {
+ debug_assert_eq!(next_leaf, total, "leaf accounting must cover the
type");
+ }
+ projected.ok().flatten()
+}
+
/// Number of Parquet leaf columns a (Parquet-derived) Arrow type occupies.
pub(crate) fn count_leaves(dt: &DataType) -> usize {
match dt {
@@ -249,6 +278,54 @@ fn clip_type(
}
}
+/// Rebuilds one physical type subtree for `kept` leaf offsets.
+///
+/// `Ok(None)` means no selected leaf lies below this subtree. `Err(())` means
+/// only part of an unsupported wrapper was selected, so the caller must fall
+/// back to reading the full root.
+fn project_type_for_leaves(
+ physical: &DataType,
+ kept: &[usize],
+ next_leaf: &mut usize,
+) -> Result<Option<DataType>, ()> {
+ match physical {
+ DataType::Struct(fields) => {
+ let mut projected = Vec::with_capacity(fields.len());
+ for field in fields {
+ if let Some(dt) =
+ project_type_for_leaves(field.data_type(), kept,
next_leaf)?
+ {
+ projected.push(field_with_type(field, dt));
+ }
+ }
+ Ok((!projected.is_empty()).then(||
DataType::Struct(projected.into())))
+ }
+ DataType::List(item) => {
+ Ok(project_type_for_leaves(item.data_type(), kept, next_leaf)?
+ .map(|projected| DataType::List(field_with_type(item,
projected))))
+ }
+ DataType::LargeList(item) => {
+ Ok(project_type_for_leaves(item.data_type(), kept, next_leaf)?
+ .map(|projected| DataType::LargeList(field_with_type(item,
projected))))
+ }
+ _ => {
+ let start = *next_leaf;
+ let end = start + count_leaves(physical);
+ let selected_start = kept.partition_point(|leaf| *leaf < start);
+ let selected_end = kept.partition_point(|leaf| *leaf < end);
+ let selected = selected_end - selected_start;
+ *next_leaf = end;
+ if selected == 0 {
+ Ok(None)
+ } else if selected == end - start {
+ Ok(Some(physical.clone()))
+ } else {
+ Err(())
+ }
+ }
+ }
+}
+
/// Keep every leaf of `dt` (no pruning below this point); returns `dt`
/// unchanged since nothing was clipped.
fn keep_all_leaves(
@@ -455,6 +532,38 @@ mod tests {
assert_eq!(emitted, list_of(struct_of(vec![int64("x"), utf8("y")])));
}
+ /// A union assembled from two casts below a shared List<Struct> prefix
+ /// reconstructs one emitted type in physical field order.
+ #[test]
+ fn type_for_leaf_subset_unions_nested_cast_leaves() {
+ let physical = list_of(struct_of(vec![int64("x"), utf8("y"),
utf8("pad")]));
+ let emitted = type_for_leaf_subset(&physical, &[0, 1]).unwrap();
+ assert_eq!(emitted, list_of(struct_of(vec![int64("x"), utf8("y")])));
+ }
+
+ /// A complete leaf set has nothing to prune, so callers must retain the
+ /// original root type rather than treating it as a clipped projection.
+ #[test]
+ fn type_for_leaf_subset_rejects_complete_selection() {
+ let physical = struct_of(vec![int64("x"), utf8("y"), utf8("pad")]);
+ assert!(type_for_leaf_subset(&physical, &[0, 1, 2]).is_none());
+ }
+
+ /// Partial selection below a wrapper cast clipping does not understand
+ /// must fall back instead of predicting a type the reader may not emit.
+ #[test]
+ fn type_for_leaf_subset_rejects_unsupported_partial_wrapper() {
+ let physical = DataType::FixedSizeList(
+ Arc::new(Field::new(
+ "item",
+ struct_of(vec![int64("x"), utf8("pad")]),
+ true,
+ )),
+ 2,
+ );
+ assert!(type_for_leaf_subset(&physical, &[0]).is_none());
+ }
+
/// Two levels of `list<struct>` nesting, the inner one also narrowed,
/// the `events: array<struct<..., items: array<struct<...>>>>` shape
/// reported in `datafusion-comet#4859`, where a sibling struct field at
diff --git a/datafusion/datasource-parquet/src/projection_read_plan.rs
b/datafusion/datasource-parquet/src/projection_read_plan.rs
index 0381808511..c00478f562 100644
--- a/datafusion/datasource-parquet/src/projection_read_plan.rs
+++ b/datafusion/datasource-parquet/src/projection_read_plan.rs
@@ -45,6 +45,7 @@ use datafusion_physical_expr::{PhysicalExpr,
ScalarFunctionExpr};
use crate::nested_schema_pruning::{
CastColumnAccess, clip_for_cast, contains_struct, count_leaves,
field_with_type,
+ type_for_leaf_subset,
};
/// The result of resolving which Parquet leaf columns and Arrow schema fields
@@ -577,22 +578,16 @@ pub(crate) fn build_projection_read_plan(
/// - roots referenced as whole columns keep every leaf and their full
/// physical field (whole-column reads take precedence; cast accesses on
/// such roots were already dropped by the caller);
-/// - roots consumed through a cast, and not also through a `get_field`
-/// access on the same root, keep only the leaves the cast target names
-/// (see `crate::nested_schema_pruning`);
+/// - roots consumed through one or more casts keep the union of the leaves
+/// their targets name (see `crate::nested_schema_pruning`);
+/// - a root consumed through both casts and `get_field` accesses keeps the
+/// union of both access kinds;
/// - roots consumed only through `get_field` accesses keep the union of the
/// leaves those accesses reach, as before;
/// - any other referenced root, a cast that can't be safely clipped (see
-/// `nested_schema_pruning::clip_for_cast`), a root reached by two casts
-/// with *different* targets (a projection can consume the same column
-/// through more than one narrowing cast, e.g.
-/// `SELECT CAST(s AS STRUCT(a)), CAST(s AS STRUCT(b)) FROM t`; clipping to
-/// either target alone would starve the other), or a root reached by both a
-/// cast and a `get_field` access (not produced by
-/// `DefaultPhysicalExprAdapter`, which always routes a `get_field` over a
-/// narrowed column through the same cast rather than a separate access,
-/// but a custom `PhysicalExprAdapter` could in principle inject both),
-/// falls back to a full read of that root.
+/// `nested_schema_pruning::clip_for_cast`), or a merged leaf set whose
+/// emitted Arrow type can't be derived safely, falls back to a full read of
+/// that root.
fn build_read_plan_with_cast_clipping(
file_schema: &Schema,
schema_descr: &SchemaDescriptor,
@@ -601,45 +596,22 @@ fn build_read_plan_with_cast_clipping(
cast_accesses: &[CastColumnAccess],
) -> ParquetReadPlan {
let whole_roots: BTreeSet<usize> =
whole_root_indices.iter().copied().collect();
- let struct_access_roots: BTreeSet<usize> =
- struct_accesses.iter().map(|a| a.root_index).collect();
// Every referenced root's Parquet leaves, grouped in one pass over the
// schema descriptor rather than one `leaf_indices_for_roots` scan per
// root (this function may look up several roots).
let leaves_by_root = leaves_grouped_by_root(schema_descr);
- // Root -> (absolute kept leaf indices, cast-clipped Arrow type) for
- // roots successfully clipped via a cast.
- let mut clipped_by_root: BTreeMap<usize, (Vec<usize>, DataType)> =
BTreeMap::new();
+ // Root -> relative leaf offsets required by every narrowing cast on that
+ // root. A set makes repeated and overlapping targets a natural union.
+ let mut kept_offsets_by_root: BTreeMap<usize, BTreeSet<usize>> =
BTreeMap::new();
// Roots with a cast access that must fall back to a full read.
let mut fallback_roots: BTreeSet<usize> = BTreeSet::new();
- // The cast target already clipped for a root, so a second cast on the
- // same root can be recognised as either a repeat (same target: nothing to
- // do) or a conflict (different target: neither clip is valid on its own).
- let mut clipped_target_by_root: BTreeMap<usize, &DataType> =
BTreeMap::new();
for access in cast_accesses {
let root = access.root_index;
if whole_roots.contains(&root) || fallback_roots.contains(&root) {
continue;
}
- if let Some(previous) = clipped_target_by_root.get(&root) {
- if **previous != access.target_type {
- // The projection consumes this root through two different
- // narrowing casts. Each cast only needs its own leaves, but
- // the mask is per column: clipping to the first target would
- // silently null-fill whatever the second one needs. Read the
- // whole root instead.
- clipped_by_root.remove(&root);
- clipped_target_by_root.remove(&root);
- fallback_roots.insert(root);
- }
- continue;
- }
- if struct_access_roots.contains(&root) {
- fallback_roots.insert(root);
- continue;
- }
let physical_type = file_schema.field(root).data_type();
let root_leaves = leaves_by_root.get(&root).map_or(&[][..],
Vec::as_slice);
@@ -654,33 +626,73 @@ fn build_read_plan_with_cast_clipping(
}
match clip_for_cast(physical_type, &access.target_type) {
- Some((kept_offsets, pruned_type)) => {
- let start = root_leaves[0];
- let absolute = kept_offsets.into_iter().map(|o| start +
o).collect();
- clipped_by_root.insert(root, (absolute, pruned_type));
- clipped_target_by_root.insert(root, &access.target_type);
+ Some((kept_offsets, _pruned_type)) => {
+ kept_offsets_by_root
+ .entry(root)
+ .or_default()
+ .extend(kept_offsets);
}
// Nothing prunable for this cast: every leaf is consumed.
None => {
+ kept_offsets_by_root.remove(&root);
fallback_roots.insert(root);
}
}
}
+ // Add leaves reached through `get_field` to cast roots. The resolver
+ // returns absolute Parquet leaf indices; convert them back to offsets in
+ // their root so they can share the same union as cast clipping.
+ let struct_access_tree = StructAccessTree::from_accesses(struct_accesses);
+ for leaf in resolve_struct_field_leaves(&struct_access_tree, schema_descr)
{
+ let root = schema_descr.get_column_root_idx(leaf);
+ if !kept_offsets_by_root.contains_key(&root) {
+ continue;
+ }
+ let Some(offset) = leaves_by_root
+ .get(&root)
+ .and_then(|root_leaves| root_leaves.binary_search(&leaf).ok())
+ else {
+ kept_offsets_by_root.remove(&root);
+ fallback_roots.insert(root);
+ continue;
+ };
+ kept_offsets_by_root
+ .get_mut(&root)
+ .expect("root presence checked above")
+ .insert(offset);
+ }
+
+ // Derive the reader's one emitted Arrow type from each merged leaf set.
+ // Any unsupported partial wrapper retains the total fallback guarantee.
+ let mut clipped_by_root: BTreeMap<usize, (Vec<usize>, DataType)> =
BTreeMap::new();
+ for (root, kept_offsets) in kept_offsets_by_root {
+ if fallback_roots.contains(&root) {
+ continue;
+ }
+ let physical_type = file_schema.field(root).data_type();
+ let root_leaves = leaves_by_root.get(&root).map_or(&[][..],
Vec::as_slice);
+ let kept_offsets = kept_offsets.into_iter().collect::<Vec<_>>();
+ let Some(pruned_type) = type_for_leaf_subset(physical_type,
&kept_offsets) else {
+ fallback_roots.insert(root);
+ continue;
+ };
+ let absolute = kept_offsets
+ .into_iter()
+ .map(|offset| root_leaves[offset])
+ .collect();
+ clipped_by_root.insert(root, (absolute, pruned_type));
+ }
+
// `get_field` accesses on roots not already read in full (as a whole
// column, or as a cast that fell back) keep the existing (non-cast) leaf
// resolution.
let get_field_accesses: Vec<StructFieldAccess> = struct_accesses
.iter()
.filter(|a| {
- // A root carrying a `get_field` access is put into
- // `fallback_roots` before any clip is attempted (see the loop
- // above), so it can never also be clipped. Assert that rather
- // than re-testing it here, so a future reordering trips the
- // assert instead of silently changing which leaves are read.
- debug_assert!(!clipped_by_root.contains_key(&a.root_index));
!whole_roots.contains(&a.root_index)
&& !fallback_roots.contains(&a.root_index)
+ && !clipped_by_root.contains_key(&a.root_index)
})
.cloned()
.collect();
@@ -1358,10 +1370,9 @@ mod test {
);
}
- /// Once conflicting cast targets have demoted a root to a full read, a
- /// *third* cast on it must not resurrect the clip.
+ /// A repeated third cast does not duplicate leaves or widen the union.
#[test]
- fn build_projection_read_plan_keeps_full_read_after_a_third_cast() {
+ fn build_projection_read_plan_keeps_union_after_a_third_cast() {
let (file_schema, metadata) = write_id_struct_file();
let schema_descr = metadata.file_metadata().schema_descr();
@@ -1374,10 +1385,19 @@ mod test {
assert_eq!(
read_plan.projection_mask,
- ProjectionMask::leaves(schema_descr, [1, 2, 3])
+ ProjectionMask::leaves(schema_descr, [1, 2])
);
let s_field = read_plan.projected_schema.field_with_name("s").unwrap();
- assert_eq!(s_field.data_type(), file_schema.field(1).data_type());
+ assert_eq!(
+ s_field.data_type(),
+ &DataType::Struct(
+ vec![
+ Arc::new(Field::new("value", DataType::Int32, false)),
+ Arc::new(Field::new("label", DataType::Utf8, false)),
+ ]
+ .into()
+ )
+ );
}
/// A whole-column reference wins over a `get_field` access on the same
@@ -1490,12 +1510,10 @@ mod test {
);
}
- /// Two casts on the same root with *different* targets cannot both be
- /// served by one mask: clipping to either target alone would null-fill
- /// whatever the other one needs (or fail its runtime struct-compatibility
- /// check outright). Read the whole root instead.
+ /// Two casts on the same root with disjoint targets share the union of
+ /// their leaves, while an unreferenced sibling remains pruned.
#[test]
- fn build_projection_read_plan_falls_back_on_conflicting_cast_targets() {
+ fn build_projection_read_plan_unions_disjoint_cast_targets() {
let (file_schema, metadata) = write_id_struct_file();
let schema_descr = metadata.file_metadata().schema_descr();
@@ -1515,11 +1533,68 @@ mod test {
assert_eq!(
read_plan.projection_mask,
- ProjectionMask::leaves(schema_descr, [1, 2, 3]),
- "every leaf of `s` must be read so both casts see their fields"
+ ProjectionMask::leaves(schema_descr, [1, 2]),
+ "the union must serve both casts without reading `s.pad`"
);
let s_field = read_plan.projected_schema.field_with_name("s").unwrap();
- assert_eq!(s_field.data_type(), file_schema.field(1).data_type());
+ assert_eq!(
+ s_field.data_type(),
+ &DataType::Struct(
+ vec![
+ Arc::new(Field::new("value", DataType::Int32, false)),
+ Arc::new(Field::new("label", DataType::Utf8, false)),
+ ]
+ .into()
+ )
+ );
+ }
+
+ /// A union that covers every leaf falls back to the full root type.
+ #[test]
+ fn build_projection_read_plan_falls_back_for_complete_cast_union() {
+ let (file_schema, metadata) = write_id_struct_file();
+ let schema_descr = metadata.file_metadata().schema_descr();
+
+ let exprs = vec![
+ cast_to_struct("s", 1, vec![("value", DataType::Int32)]),
+ cast_to_struct(
+ "s",
+ 1,
+ vec![("label", DataType::Utf8), ("pad", DataType::Utf8)],
+ ),
+ ];
+ let read_plan = build_projection_read_plan(exprs, &file_schema,
schema_descr);
+
+ assert_eq!(
+ read_plan.projection_mask,
+ ProjectionMask::leaves(schema_descr, [1, 2, 3])
+ );
+ assert_eq!(
+ read_plan.projected_schema.field_with_name("s").unwrap(),
+ file_schema.field(1)
+ );
+ }
+
+ /// Overlapping cast targets deduplicate their shared leaves.
+ #[test]
+ fn build_projection_read_plan_unions_overlapping_cast_targets() {
+ let (file_schema, metadata) = write_id_struct_file();
+ let schema_descr = metadata.file_metadata().schema_descr();
+
+ let exprs = vec![
+ cast_to_struct("s", 1, vec![("value", DataType::Int32)]),
+ cast_to_struct(
+ "s",
+ 1,
+ vec![("value", DataType::Int32), ("label", DataType::Utf8)],
+ ),
+ ];
+ let read_plan = build_projection_read_plan(exprs, &file_schema,
schema_descr);
+
+ assert_eq!(
+ read_plan.projection_mask,
+ ProjectionMask::leaves(schema_descr, [1, 2])
+ );
}
/// The struct fast-path gate looks at the *projected* columns, not at
@@ -1546,12 +1621,10 @@ mod test {
assert_eq!(read_plan.projected_schema.fields().len(), 1);
}
- /// A root reached by both a narrowing cast and a `get_field` access (not
- /// producible by `DefaultPhysicalExprAdapter`, but a custom
- /// `PhysicalExprAdapter` could inject both) falls back to a full read of
- /// that root rather than attempting to union the two leaf sets.
+ /// A root reached by both a narrowing cast and a disjoint `get_field`
+ /// access shares the union of their leaves.
#[test]
- fn
build_projection_read_plan_falls_back_when_cast_and_get_field_share_a_root() {
+ fn build_projection_read_plan_unions_cast_and_get_field_on_one_root() {
let (file_schema, metadata) = write_id_struct_file();
let schema_descr = metadata.file_metadata().schema_descr();
@@ -1575,8 +1648,7 @@ mod test {
let read_plan = build_projection_read_plan(exprs, &file_schema,
schema_descr);
- // Every leaf of `s` is read (full fallback), not just value/label.
- let expected_mask = ProjectionMask::leaves(schema_descr, [1, 2, 3]);
+ let expected_mask = ProjectionMask::leaves(schema_descr, [1, 2]);
assert_eq!(read_plan.projection_mask, expected_mask);
let s_field = read_plan.projected_schema.field_with_name("s").unwrap();
@@ -1586,7 +1658,6 @@ mod test {
vec![
Arc::new(Field::new("value", DataType::Int32, false)),
Arc::new(Field::new("label", DataType::Utf8, false)),
- Arc::new(Field::new("pad", DataType::Utf8, false)),
]
.into()
),
diff --git
a/datafusion/sqllogictest/test_files/parquet_nested_schema_pruning.slt
b/datafusion/sqllogictest/test_files/parquet_nested_schema_pruning.slt
index d936a89beb..0aa171824f 100644
--- a/datafusion/sqllogictest/test_files/parquet_nested_schema_pruning.slt
+++ b/datafusion/sqllogictest/test_files/parquet_nested_schema_pruning.slt
@@ -473,10 +473,8 @@ set datafusion.execution.parquet.pushdown_filters = false;
# Query-level casts. `ProjectionExec` is merged into the scan, so a `CAST`
# written in the query reaches the same read-plan analysis as an
# adapter-inserted one — including one column consumed through two *different*
-# cast targets, which no single clipped read can serve. Clipping to one
-# target's leaves would leave the other cast reading a struct that is missing
-# the fields it names, which `cast_column` either null-fills (wrong results)
-# or, for disjoint targets, rejects outright.
+# cast targets. The read plan unions the targets' leaves so both casts receive
+# the fields they name without reading unreferenced siblings.
#
# `exact` infers its schema from the file, so no adapter cast is interposed
# and the casts below are the only ones the scan sees.
@@ -515,6 +513,25 @@ FROM exact ORDER BY id;
{x: 200} {x: 200, y: s2}
NULL NULL
+# A cast plus a separate get_field access on the same physical root.
+query ?T
+SELECT CAST(s AS STRUCT<x BIGINT>) AS q0, s['y'] AS q1
+FROM exact ORDER BY id;
+----
+{x: 100} s1
+{x: 200} s2
+NULL NULL
+
+# The targets diverge below a shared ARRAY<STRUCT<...>> prefix.
+query ??
+SELECT CAST(events AS ARRAY<STRUCT<x BIGINT>>) AS q0,
+ CAST(events AS ARRAY<STRUCT<y VARCHAR>>) AS q1
+FROM exact ORDER BY id;
+----
+[{x: 10}] [{y: a1}]
+[{x: 20}, {x: 21}] [{y: b1}, {y: b2}]
+NULL NULL
+
# Repeated identical targets still clip.
query ??
SELECT CAST(s AS STRUCT<x BIGINT>) AS q0,
@@ -534,14 +551,25 @@ FROM exact ORDER BY id;
{x: 200} {x: 200, y: s2, pad: sp2}
NULL NULL
-# The conflicting-target fallback reads the whole column -- exactly what a
-# scan with no clipping at all reads, and never more. These two must match:
-# the first falls back, the second never clips in the first place.
+# The disjoint targets read their two-leaf union and skip `pad`.
query TT
explain analyze select CAST(s AS STRUCT<x BIGINT>) AS q0, CAST(s AS STRUCT<pad
VARCHAR>) AS q1 from exact;
----
-Plan with Metrics DataSourceExec: <slt:ignore>metrics=[output_rows=3,
<slt:ignore>bytes_scanned=219<slt:ignore>]
+Plan with Metrics DataSourceExec: <slt:ignore>metrics=[output_rows=3,
<slt:ignore>bytes_scanned=148<slt:ignore>]
+
+# The cast/get_field union likewise skips `pad`.
+query TT
+explain analyze select CAST(s AS STRUCT<x BIGINT>) AS q0, s['y'] AS q1 from
exact;
+----
+Plan with Metrics DataSourceExec: <slt:ignore>metrics=[output_rows=3,
<slt:ignore>bytes_scanned=146<slt:ignore>]
+
+# The nested union reads x/y but skips both pad leaves.
+query TT
+explain analyze select CAST(events AS ARRAY<STRUCT<x BIGINT>>) AS q0,
CAST(events AS ARRAY<STRUCT<y VARCHAR>>) AS q1 from exact;
+----
+Plan with Metrics DataSourceExec: <slt:ignore>metrics=[output_rows=3,
<slt:ignore>bytes_scanned=172<slt:ignore>]
+# A full-root baseline remains larger than every union above.
query TT
explain analyze select s from exact;
----
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]