This is an automated email from the ASF dual-hosted git repository.

HyukjinKwon pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/spark-connect-rust.git


The following commit(s) were added to refs/heads/master by this push:
     new fbd6af5  [SPARK-60585][SPARK-60588][CONNECT] Share field names across 
Arrow rows and structs
fbd6af5 is described below

commit fbd6af5763bae5cd572ba4438026e675165ba70a
Author: DB Tsai <[email protected]>
AuthorDate: Tue Oct 27 14:30:18 2026 -0700

    [SPARK-60585][SPARK-60588][CONNECT] Share field names across Arrow rows and 
structs
    
    JIRA: [SPARK-60585](https://issues.apache.org/jira/browse/SPARK-60585), 
[SPARK-60588](https://issues.apache.org/jira/browse/SPARK-60588)
    
    ### What changes were proposed in this pull request?
    
    Share immutable names across Arrow-decoded rows and nested structs, 
including structs inside arrays/maps. Local Row constructors retain owned 
names; Catalog and DataFrame use one decoder.
    
    ### Why are the changes needed?
    
    Apple M3 Max/macOS arm64; Apache master `a85e474` → combined PR head 
`834402b` (top-level and nested sharing; no incremental nested-only 
comparison). The [benchmark 
harness](https://github.com/dbtsai/pyspark-connect-benchmark) uses canned 
Arrow/gRPC fixtures, 23 frozen cases, 30 fresh processes per case/client and 
three rotating clients. screen: 2,070 recorded, 2,010 successful, 60 failed, 0 
missing; confirm: 2,070 recorded, 2,010 successful, 60 failed, 0 missing.
    
    Selected confirm medians, baseline → candidate; scans fully consume output. 
Held RSS includes allocator history.
    
    | Workload | CPU ms | Wall ms | Held RSS MiB |
    | --- | ---: | ---: | ---: |
    | 16 × 256, nested d4/inner16, full scan | 94.08 → 88.01 | 96.52 → 90.42 | 
124.95 → 120.66 |
    | 16 × 256, nested d16/inner8, full scan | 202.87 → 185.94 | 207.18 → 
190.45 | 145.41 → 140.02 |
    | 100,000 × 50 collect | 242.12 → 127.38 | 234.48 → 118.91 | 680.85 → 
447.13 |
    | 4,096 × 512 collect | 87.18 → 39.68 | 83.02 → 35.42 | 297.91 → 214.72 |
    
    **Overall frozen verdict: rejected.** Arrow `Duration(Microsecond)` is 
unsupported in both Rust builds (30 failed mixed-record samples each per run); 
timing guards also remain uncertain. No accepted overall CPU or memory 
improvement is claimed. Independent full confirmation is included.
    
    ### Does this PR introduce _any_ user-facing change?
    
    Rust API break: `Value::Struct` now holds public `StructValue` instead of 
`Vec<(String, Value)>`, sharing names while owning values. Migrate construction 
to `Value::Struct(Row::new(fields, values).into())` and use its field/value 
accessors. Tested Python Row behavior is unchanged. Measured arm64 `Row` and 
`Value` both remain 48 bytes.
    
    ### How was this patch tested?
    
    412 Rust checks passed; all core integration targets compiled with and 
without `wasm-udf`. The 108-class Python API snapshot and 11 behavior/fixture 
comparisons pass, as do formatting and core Clippy. Local workspace Clippy 
lacks the wasm standard library; local live-server parity was unavailable. 
Exact-head CI: 12 checks passed; Connect parity is still running; docs 
deployment was skipped.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Yes. Generated-by: OpenAI Codex CLI 0.163.0-alpha.10.2.
    
    This pull request and its description were written by Isaac.
    
    Closes #105 from dbtsai/codex/share-arrow-row-field-names.
    
    Authored-by: DB Tsai <[email protected]>
    Signed-off-by: Hyukjin Kwon <[email protected]>
---
 crates/pyspark-rs/src/row.rs          |  11 +-
 crates/pyspark-rs/src/session.rs      |  18 +-
 crates/spark-connect/src/catalog.rs   |  99 ++++---
 crates/spark-connect/src/dataframe.rs | 479 ++++++++++++++++++++++++++++++++--
 crates/spark-connect/src/row.rs       | 424 +++++++++++++++++++++++++++++-
 crates/spark-connect/src/session.rs   | 263 +++++++++++++++++--
 6 files changed, 1167 insertions(+), 127 deletions(-)

diff --git a/crates/pyspark-rs/src/row.rs b/crates/pyspark-rs/src/row.rs
index 524bb73..dfdf506 100644
--- a/crates/pyspark-rs/src/row.rs
+++ b/crates/pyspark-rs/src/row.rs
@@ -70,15 +70,10 @@ pub fn value_to_py<'py>(py: Python<'py>, v: &Value) -> 
PyResult<Bound<'py, PyAny
             }
             dict.into_any()
         }
-        Value::Struct(fields) => {
+        Value::Struct(row) => {
             // A struct value materializes as a Row (named fields), matching 
PySpark,
-            // rather than a plain dict.
-            let names: Vec<String> = fields.iter().map(|(k, _)| 
k.clone()).collect();
-            let mut vals: Vec<Value> = Vec::with_capacity(fields.len());
-            for (_, val) in fields {
-                vals.push(val.clone());
-            }
-            Py::new(py, PyRow::new(spark_connect::row::Row::new(names, vals)))?
+            // rather than a plain dict. Cloning keeps shared names and 
independent values.
+            Py::new(py, PyRow::new(row.clone().into()))?
                 .into_bound(py)
                 .into_any()
         }
diff --git a/crates/pyspark-rs/src/session.rs b/crates/pyspark-rs/src/session.rs
index 437cc80..5650e60 100644
--- a/crates/pyspark-rs/src/session.rs
+++ b/crates/pyspark-rs/src/session.rs
@@ -915,9 +915,11 @@ fn value_to_datatype(v: &Value) -> DataType {
                 contains_null: true,
             }
         }
-        Value::Struct(fields) => DataType::Struct {
-            fields: fields
+        Value::Struct(row) => DataType::Struct {
+            fields: row
+                .fields()
                 .iter()
+                .zip(row.values())
                 .map(|(n, val)| spark_connect::types::StructField {
                     name: n.clone(),
                     data_type: value_to_datatype(val),
@@ -1004,17 +1006,7 @@ pub(crate) fn py_to_value(obj: &Bound<'_, PyAny>) -> 
PyResult<Value> {
     // pyspark Row -> struct: its named fields (values already core Values), 
so a nested
     // Row inside createDataFrame data (e.g. Row(s=Row(x=1))) converts 
recursively.
     if let Ok(pyrow) = obj.extract::<PyRef<crate::row::PyRow>>() {
-        let fields = pyrow.row.fields();
-        let values = pyrow.row.values();
-        let mut out = Vec::with_capacity(values.len());
-        for (i, v) in values.iter().enumerate() {
-            let name = fields
-                .get(i)
-                .cloned()
-                .unwrap_or_else(|| format!("_{}", i + 1));
-            out.push((name, v.clone()));
-        }
-        return Ok(Value::Struct(out));
+        return Ok(Value::Struct(pyrow.row.clone().into()));
     }
 
     // dict -> map (keys coerced to their string form, values recursively 
converted)
diff --git a/crates/spark-connect/src/catalog.rs 
b/crates/spark-connect/src/catalog.rs
index 5de93f8..7ce672a 100644
--- a/crates/spark-connect/src/catalog.rs
+++ b/crates/spark-connect/src/catalog.rs
@@ -6,7 +6,7 @@ use spark_connect_core::error::{Result, SparkError};
 use spark_connect_core::runtime::block_on;
 use spark_connect_proto as proto;
 
-use crate::dataframe::DataFrame;
+use crate::dataframe::{decode_arrow_batch, DataFrame};
 use crate::row::{Row, Value};
 use crate::session::SparkSession;
 
@@ -1083,55 +1083,54 @@ impl Catalog {
     }
 }
 
-/// Decode an Arrow batch into rows.
-pub(crate) fn decode_arrow_batch(
-    batch: &proto::execute_plan_response::ArrowBatch,
-) -> Result<Vec<Row>> {
-    use arrow::ipc::reader::StreamReader;
-    use std::io::Cursor;
-
-    if batch.data.is_empty() {
-        return Ok(vec![]);
-    }
-
-    let cursor = Cursor::new(&batch.data);
-    let mut reader = StreamReader::try_new(cursor, None).map_err(|e| {
-        SparkError::connect_msg(format!("Failed to create Arrow stream reader: 
{}", e))
-    })?;
-
-    let mut rows = vec![];
-
-    while let Some(record_batch) = reader
-        .next()
-        .transpose()
-        .map_err(|e| SparkError::connect_msg(format!("Failed to decode Arrow 
batch: {}", e)))?
-    {
-        let schema = record_batch.schema();
-        let num_rows = record_batch.num_rows();
-        let num_cols = record_batch.num_columns();
-
-        for row_idx in 0..num_rows {
-            let mut field_names = vec![];
-            let mut values = vec![];
-
-            for col_idx in 0..num_cols {
-                let field_name = schema.field(col_idx).name().clone();
-                let column = record_batch.column(col_idx);
-
-                let value = arrow_value_at(column.as_ref(), row_idx)?;
-                field_names.push(field_name);
-                values.push(value);
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn parse_database_preserves_metadata_and_nullable_columns() {
+        let fields = vec![
+            "name".to_string(),
+            "catalog".to_string(),
+            "description".to_string(),
+            "locationUri".to_string(),
+        ];
+        let populated = Row::new(
+            fields.clone(),
+            vec![
+                Value::String("one".to_string()),
+                Value::String("spark_catalog".to_string()),
+                Value::String("first database".to_string()),
+                Value::String("file:/one".to_string()),
+            ],
+        );
+        let nullable = Row::new(
+            fields,
+            vec![
+                Value::String("two".to_string()),
+                Value::Null,
+                Value::Null,
+                Value::String("file:/two".to_string()),
+            ],
+        );
+
+        assert_eq!(
+            parse_database(&populated),
+            Database {
+                name: "one".to_string(),
+                catalog: Some("spark_catalog".to_string()),
+                description: Some("first database".to_string()),
+                location_uri: "file:/one".to_string(),
             }
-
-            rows.push(Row::new(field_names, values));
-        }
+        );
+        assert_eq!(
+            parse_database(&nullable),
+            Database {
+                name: "two".to_string(),
+                catalog: None,
+                description: None,
+                location_uri: "file:/two".to_string(),
+            }
+        );
     }
-
-    Ok(rows)
-}
-
-/// Extract a value at a row index from an Arrow array. Delegates to the 
single,
-/// comprehensive decoder in `dataframe` so catalog results support every type 
too.
-fn arrow_value_at(array: &dyn arrow::array::Array, index: usize) -> 
Result<Value> {
-    crate::dataframe::arrow_value_at(array, index)
 }
diff --git a/crates/spark-connect/src/dataframe.rs 
b/crates/spark-connect/src/dataframe.rs
index 0a273cc..72dff2f 100644
--- a/crates/spark-connect/src/dataframe.rs
+++ b/crates/spark-connect/src/dataframe.rs
@@ -13,7 +13,7 @@ use spark_connect_proto as proto;
 use crate::column::Column;
 use crate::expression::{Expression, ExpressionNode};
 use crate::plan::{AggregateGroupType, JoinType, LogicalPlan, SetOpType};
-use crate::row::{Row, Value};
+use crate::row::{Row, StructValue, Value};
 use crate::session::{ExecutionInfo, SparkSession};
 use crate::types::DataType;
 use crate::udf::CommonInlineUserDefinedFunctionExpression;
@@ -2053,7 +2053,9 @@ pub(crate) fn assign_plan_ids(
 }
 
 /// Decode an Arrow batch into rows.
-fn decode_arrow_batch(batch: &proto::execute_plan_response::ArrowBatch) -> 
Result<Vec<Row>> {
+pub(crate) fn decode_arrow_batch(
+    batch: &proto::execute_plan_response::ArrowBatch,
+) -> Result<Vec<Row>> {
     use arrow::ipc::reader::StreamReader;
     use std::io::Cursor;
 
@@ -2075,22 +2077,29 @@ fn decode_arrow_batch(batch: 
&proto::execute_plan_response::ArrowBatch) -> Resul
     {
         let schema = record_batch.schema();
         let num_rows = record_batch.num_rows();
-        let num_cols = record_batch.num_columns();
+        if num_rows == 0 {
+            continue;
+        }
+        let field_names = schema
+            .fields()
+            .iter()
+            .map(|field| field.name().clone())
+            .collect::<Arc<[String]>>();
+        let layouts = record_batch
+            .columns()
+            .iter()
+            .map(|column| ArrowValueLayout::new(column.data_type()))
+            .collect::<Vec<_>>();
 
         for row_idx in 0..num_rows {
-            let mut field_names = vec![];
             let mut values = vec![];
 
-            for col_idx in 0..num_cols {
-                let field_name = schema.field(col_idx).name().clone();
-                let column = record_batch.column(col_idx);
-
-                let value = arrow_value_at(column.as_ref(), row_idx)?;
-                field_names.push(field_name);
+            for (column, layout) in 
record_batch.columns().iter().zip(&layouts) {
+                let value = arrow_value_at_with_layout(column.as_ref(), 
row_idx, layout)?;
                 values.push(value);
             }
 
-            rows.push(Row::new(field_names, values));
+            rows.push(Row::with_shared_fields(Arc::clone(&field_names), 
values));
         }
     }
 
@@ -2232,7 +2241,60 @@ fn map_key_to_string(v: Value) -> String {
     }
 }
 
-pub(crate) fn arrow_value_at(array: &dyn arrow::array::Array, index: usize) -> 
Result<Value> {
+/// Immutable decoding metadata prepared once for each column in a record 
batch.
+/// Container layouts follow their children so struct names are also shared 
inside
+/// lists and maps, without retaining the Arrow arrays after decoding.
+enum ArrowValueLayout {
+    Scalar,
+    List(Box<Self>),
+    Struct {
+        names: Arc<[String]>,
+        children: Vec<Self>,
+        is_variant: bool,
+    },
+    Map(Box<(Self, Self)>),
+}
+
+impl ArrowValueLayout {
+    fn new(data_type: &arrow::datatypes::DataType) -> Self {
+        use arrow::datatypes::DataType;
+        match data_type {
+            DataType::List(field) => 
Self::List(Box::new(Self::new(field.data_type()))),
+            DataType::Struct(fields) => Self::Struct {
+                names: fields.iter().map(|field| 
field.name().clone()).collect(),
+                children: fields
+                    .iter()
+                    .map(|field| Self::new(field.data_type()))
+                    .collect(),
+                is_variant: fields.iter().any(|field| {
+                    field
+                        .metadata()
+                        .get("variant")
+                        .is_some_and(|value| value == "true")
+                }),
+            },
+            DataType::Map(entries, _) => match entries.data_type() {
+                DataType::Struct(fields) => Self::Map(Box::new((
+                    Self::new(fields[0].data_type()),
+                    Self::new(fields[1].data_type()),
+                ))),
+                _ => unreachable!("Arrow map entries must be a struct"),
+            },
+            _ => Self::Scalar,
+        }
+    }
+}
+
+#[cfg(test)]
+fn arrow_value_at(array: &dyn arrow::array::Array, index: usize) -> 
Result<Value> {
+    arrow_value_at_with_layout(array, index, 
&ArrowValueLayout::new(array.data_type()))
+}
+
+fn arrow_value_at_with_layout(
+    array: &dyn arrow::array::Array,
+    index: usize,
+    value_layout: &ArrowValueLayout,
+) -> Result<Value> {
     use arrow::array::*;
 
     if array.is_null(index) {
@@ -2332,28 +2394,37 @@ pub(crate) fn arrow_value_at(array: &dyn 
arrow::array::Array, index: usize) -> R
     }
     // Nested: list, struct, map (recurse).
     if let Some(arr) = array.as_any().downcast_ref::<ListArray>() {
+        let ArrowValueLayout::List(child_layout) = value_layout else {
+            unreachable!("layout is prepared from the array's data type");
+        };
         let child = arr.value(index);
         let mut items = Vec::with_capacity(child.len());
         for i in 0..child.len() {
-            items.push(arrow_value_at(child.as_ref(), i)?);
+            items.push(arrow_value_at_with_layout(child.as_ref(), i, 
child_layout)?);
         }
         return Ok(Value::List(items));
     }
     if let Some(arr) = array.as_any().downcast_ref::<StructArray>() {
+        let ArrowValueLayout::Struct {
+            names,
+            children,
+            is_variant,
+        } = value_layout
+        else {
+            unreachable!("layout is prepared from the array's data type");
+        };
         // A VARIANT column arrives as struct<value: binary, metadata: binary> 
where the
         // `metadata` field carries arrow metadata {"variant": "true"}. 
Recognize it and
         // return a Value::Variant (raw bytes) so it materializes as a 
VariantVal (matching
         // pyspark) rather than a plain {value, metadata} struct/dict.
-        let is_variant = arr.fields().iter().any(|f| {
-            f.metadata()
-                .get("variant")
-                .map(|v| v == "true")
-                .unwrap_or(false)
-        });
-        if is_variant {
+        if *is_variant {
             let bin_field = |name: &str| -> Result<Vec<u8>> {
-                match arr.column_by_name(name) {
-                    Some(col) => match arrow_value_at(col.as_ref(), index)? {
+                match arr.fields().iter().position(|field| field.name() == 
name) {
+                    Some(col_idx) => match arrow_value_at_with_layout(
+                        arr.column(col_idx).as_ref(),
+                        index,
+                        &children[col_idx],
+                    )? {
                         Value::Binary(b) => Ok(b),
                         Value::Null => Ok(vec![]),
                         _ => Err(SparkError::connect_msg("variant field is not 
binary")),
@@ -2366,13 +2437,20 @@ pub(crate) fn arrow_value_at(array: &dyn 
arrow::array::Array, index: usize) -> R
                 metadata: bin_field("metadata")?,
             });
         }
-        let mut fields = Vec::new();
-        for (f, col) in arr.fields().iter().zip(arr.columns()) {
-            fields.push((f.name().clone(), arrow_value_at(col.as_ref(), 
index)?));
+        let mut values = Vec::with_capacity(children.len());
+        for (col, child_layout) in arr.columns().iter().zip(children) {
+            values.push(arrow_value_at_with_layout(
+                col.as_ref(),
+                index,
+                child_layout,
+            )?);
         }
-        return Ok(Value::Struct(fields));
+        return Ok(Value::Struct(StructValue::new(Arc::clone(names), values)));
     }
     if let Some(arr) = array.as_any().downcast_ref::<MapArray>() {
+        let ArrowValueLayout::Map(children) = value_layout else {
+            unreachable!("layout is prepared from the array's data type");
+        };
         let entries = arr.value(index);
         let keys = entries.column(0);
         let vals = entries.column(1);
@@ -2381,8 +2459,11 @@ pub(crate) fn arrow_value_at(array: &dyn 
arrow::array::Array, index: usize) -> R
             // A map key stringifies to its natural scalar form (e.g. `1`, 
`true`,
             // `1.5`), not the Rust enum's Debug output - a `map<int,string>` 
key must
             // be "1", never "Integer(1)".
-            let k = map_key_to_string(arrow_value_at(keys.as_ref(), i)?);
-            map.insert(k, arrow_value_at(vals.as_ref(), i)?);
+            let k = 
map_key_to_string(arrow_value_at_with_layout(keys.as_ref(), i, &children.0)?);
+            map.insert(
+                k,
+                arrow_value_at_with_layout(vals.as_ref(), i, &children.1)?,
+            );
         }
         return Ok(Value::Map(map));
     }
@@ -2446,6 +2527,312 @@ pub(crate) fn arrow_value_at(array: &dyn 
arrow::array::Array, index: usize) -> R
     )))
 }
 
+#[cfg(test)]
+mod nested_decode_tests {
+    use super::*;
+    use arrow::array::*;
+    use arrow::buffer::{NullBuffer, OffsetBuffer};
+    use arrow::datatypes::{DataType, Field, Schema};
+    use arrow::ipc::writer::StreamWriter;
+    use arrow::record_batch::RecordBatch;
+
+    fn decode_batches(columns: Vec<(&str, ArrayRef)>, repeats: usize) -> 
Vec<Row> {
+        let schema = Arc::new(Schema::new(
+            columns
+                .iter()
+                .map(|(name, array)| Field::new(*name, 
array.data_type().clone(), true))
+                .collect::<Vec<_>>(),
+        ));
+        let batch = RecordBatch::try_new(
+            schema.clone(),
+            columns.into_iter().map(|(_, array)| array).collect(),
+        )
+        .unwrap();
+        let mut data = vec![];
+        {
+            let mut writer = StreamWriter::try_new(&mut data, 
&schema).unwrap();
+            for _ in 0..repeats {
+                writer.write(&batch).unwrap();
+            }
+            writer.finish().unwrap();
+        }
+        decode_arrow_batch(&proto::execute_plan_response::ArrowBatch {
+            data: data.into(),
+            ..Default::default()
+        })
+        .unwrap()
+    }
+
+    fn as_struct(value: &Value) -> &StructValue {
+        let Value::Struct(row) = value else {
+            panic!("expected struct, got {value:?}");
+        };
+        row
+    }
+
+    #[test]
+    fn nested_names_preserve_duplicates_unicode_nulls_and_sibling_layouts() {
+        let names = ["dup", "dup", "", "名前"];
+        let fields = names
+            .iter()
+            .map(|name| Arc::new(Field::new(*name, DataType::Int32, true)))
+            .collect::<Vec<_>>();
+        let columns = (1..=4)
+            .map(|n| Arc::new(Int32Array::from(vec![Some(n), None, None])) as 
ArrayRef)
+            .collect();
+        let nested = StructArray::new(
+            fields.into(),
+            columns,
+            Some(NullBuffer::from(vec![true, true, false])),
+        );
+        let sibling = StructArray::from(vec![(
+            Arc::new(Field::new("other", DataType::Int32, false)),
+            Arc::new(Int32Array::from(vec![7, 8, 9])) as ArrayRef,
+        )]);
+        let rows = decode_batches(
+            vec![("nested", Arc::new(nested)), ("sibling", Arc::new(sibling))],
+            2,
+        );
+        assert_eq!(rows.len(), 6);
+        for batch in rows.chunks(3) {
+            let populated = as_struct(batch[0].get(0).unwrap());
+            let all_null = as_struct(batch[1].get(0).unwrap());
+            assert_eq!(populated.fields(), &names);
+            assert_eq!(
+                populated.values(),
+                &[
+                    Value::Integer(1),
+                    Value::Integer(2),
+                    Value::Integer(3),
+                    Value::Integer(4)
+                ]
+            );
+            assert_eq!(populated.get_by_name("dup"), Some(&Value::Integer(1)));
+            assert_eq!(all_null.values(), &vec![Value::Null; 4]);
+            assert_eq!(batch[2].get(0), Some(&Value::Null));
+            assert!(std::ptr::eq(populated.fields(), all_null.fields()));
+            let other = as_struct(batch[0].get(1).unwrap());
+            assert_eq!(other.fields(), &["other"]);
+            assert_eq!(other.values(), &[Value::Integer(7)]);
+        }
+    }
+
+    #[test]
+    fn names_are_shared_through_lists_maps_and_nested_structs() {
+        let leaf = StructArray::from(vec![(
+            Arc::new(Field::new("leaf", DataType::Int32, false)),
+            Arc::new(Int32Array::from(vec![1, 2, 3, 4])) as ArrayRef,
+        )]);
+        let entries = StructArray::from(vec![
+            (
+                Arc::new(Field::new("key", DataType::Utf8, false)),
+                Arc::new(StringArray::from(vec!["a", "b", "c", "d"])) as 
ArrayRef,
+            ),
+            (
+                Arc::new(Field::new("value", leaf.data_type().clone(), true)),
+                Arc::new(leaf) as ArrayRef,
+            ),
+        ]);
+        let map = MapArray::new(
+            Arc::new(Field::new("entries", entries.data_type().clone(), 
false)),
+            OffsetBuffer::new(vec![0, 1, 2, 3, 4].into()),
+            entries,
+            None,
+            false,
+        );
+        let list = ListArray::new(
+            Arc::new(Field::new("item", map.data_type().clone(), true)),
+            OffsetBuffer::new(vec![0, 2, 4].into()),
+            Arc::new(map),
+            None,
+        );
+        let outer = StructArray::from(vec![(
+            Arc::new(Field::new("items", list.data_type().clone(), true)),
+            Arc::new(list) as ArrayRef,
+        )]);
+        let rows = decode_batches(vec![("outer", Arc::new(outer))], 1);
+        let mut leaves = vec![];
+        for row in &rows {
+            let outer = as_struct(row.get(0).unwrap());
+            let Value::List(maps) = outer.get(0).unwrap() else {
+                panic!("expected list");
+            };
+            for value in maps {
+                let Value::Map(map) = value else {
+                    panic!("expected map");
+                };
+                leaves.push(as_struct(map.values().next().unwrap()));
+            }
+        }
+        assert_eq!(leaves.len(), 4);
+        for (index, leaf) in leaves.iter().enumerate() {
+            assert_eq!(leaf.fields(), &["leaf"]);
+            assert_eq!(leaf.get(0), Some(&Value::Integer(index as i32 + 1)));
+            assert!(std::ptr::eq(leaves[0].fields(), leaf.fields()));
+        }
+    }
+
+    #[test]
+    fn sliced_containers_preserve_offsets_empty_null_and_struct_values() {
+        let leaf = StructArray::new(
+            vec![Arc::new(Field::new("n", DataType::Int32, true))].into(),
+            vec![Arc::new(Int32Array::from(vec![
+                Some(0),
+                Some(1),
+                None,
+                Some(3),
+                Some(4),
+                Some(5),
+            ]))],
+            Some(NullBuffer::from(vec![true, true, false, true, true, true])),
+        );
+        let offsets = OffsetBuffer::new(vec![0, 1, 3, 3, 4, 6].into());
+        let valid = Some(NullBuffer::from(vec![true, true, true, false, 
true]));
+        let list = ListArray::new(
+            Arc::new(Field::new("item", leaf.data_type().clone(), true)),
+            offsets.clone(),
+            Arc::new(leaf.clone()),
+            valid.clone(),
+        );
+        let entries = StructArray::from(vec![
+            (
+                Arc::new(Field::new("key", DataType::Utf8, false)),
+                Arc::new(StringArray::from(vec!["k0", "k1", "k2", "k3", "k4", 
"k5"])) as ArrayRef,
+            ),
+            (
+                Arc::new(Field::new("value", leaf.data_type().clone(), true)),
+                Arc::new(leaf) as ArrayRef,
+            ),
+        ]);
+        let map = MapArray::new(
+            Arc::new(Field::new("entries", entries.data_type().clone(), 
false)),
+            offsets,
+            entries,
+            valid,
+            false,
+        );
+        let rows = decode_batches(
+            vec![
+                ("list", Arc::new(list.slice(1, 4))),
+                ("map", Arc::new(map.slice(1, 4))),
+            ],
+            1,
+        );
+        let value =
+            |n| Value::Struct(Row::new(vec!["n".to_string()], 
vec![Value::Integer(n)]).into());
+        assert_eq!(
+            rows[0].get(0),
+            Some(&Value::List(vec![value(1), Value::Null]))
+        );
+        assert_eq!(
+            rows[0].get(1),
+            Some(&Value::Map(
+                [
+                    ("k1".to_string(), value(1)),
+                    ("k2".to_string(), Value::Null)
+                ]
+                .into()
+            ))
+        );
+        assert_eq!(rows[1].get(0), Some(&Value::List(vec![])));
+        assert_eq!(rows[1].get(1), Some(&Value::Map(Default::default())));
+        assert_eq!(rows[2].get(0), Some(&Value::Null));
+        assert_eq!(rows[2].get(1), Some(&Value::Null));
+        assert_eq!(rows[3].get(0), Some(&Value::List(vec![value(4), 
value(5)])));
+        assert_eq!(
+            rows[3].get(1),
+            Some(&Value::Map(
+                [("k4".to_string(), value(4)), ("k5".to_string(), 
value(5))].into()
+            ))
+        );
+        let (Value::List(first), Value::List(last)) =
+            (rows[0].get(0).unwrap(), rows[3].get(0).unwrap())
+        else {
+            panic!("expected lists");
+        };
+        assert!(std::ptr::eq(
+            as_struct(&first[0]).fields(),
+            as_struct(&last[0]).fields()
+        ));
+        let (Value::Map(first), Value::Map(last)) =
+            (rows[0].get(1).unwrap(), rows[3].get(1).unwrap())
+        else {
+            panic!("expected maps");
+        };
+        assert!(std::ptr::eq(
+            as_struct(&first["k1"]).fields(),
+            as_struct(&last["k4"]).fields()
+        ));
+    }
+
+    #[test]
+    fn variant_marker_precedes_struct_decoding_and_preserves_errors() {
+        let marked = |name: &str, array: ArrayRef| {
+            let field = Field::new(name, array.data_type().clone(), 
true).with_metadata(
+                std::collections::HashMap::from([("variant".into(), 
"true".into())]),
+            );
+            StructArray::from(vec![(Arc::new(field), array)])
+        };
+        let binary = marked(
+            "value",
+            Arc::new(BinaryArray::from(vec![Some(b"x".as_ref()), None])),
+        );
+        assert_eq!(
+            arrow_value_at(&binary, 0).unwrap(),
+            Value::Variant {
+                value: b"x".to_vec(),
+                metadata: vec![]
+            }
+        );
+        assert_eq!(
+            arrow_value_at(&binary, 1).unwrap(),
+            Value::Variant {
+                value: vec![],
+                metadata: vec![]
+            }
+        );
+        let missing = marked("unrelated", Arc::new(Int32Array::from(vec![1])));
+        assert_eq!(
+            arrow_value_at(&missing, 0).unwrap(),
+            Value::Variant {
+                value: vec![],
+                metadata: vec![]
+            }
+        );
+        let invalid = marked("metadata", Arc::new(Int32Array::from(vec![1])));
+        assert!(arrow_value_at(&invalid, 0)
+            .unwrap_err()
+            .to_string()
+            .contains("variant field is not binary"));
+        let null_parent = StructArray::new(
+            invalid.fields().clone(),
+            invalid.columns().to_vec(),
+            Some(NullBuffer::from(vec![false])),
+        );
+        assert_eq!(arrow_value_at(&null_parent, 0).unwrap(), Value::Null);
+        let ordinary = StructArray::from(vec![
+            (
+                Arc::new(Field::new("value", DataType::Int32, false)),
+                Arc::new(Int32Array::from(vec![1])) as ArrayRef,
+            ),
+            (
+                Arc::new(Field::new("metadata", DataType::Int32, false)),
+                Arc::new(Int32Array::from(vec![2])) as ArrayRef,
+            ),
+        ]);
+        assert_eq!(
+            arrow_value_at(&ordinary, 0).unwrap(),
+            Value::Struct(
+                Row::new(
+                    vec!["value".to_string(), "metadata".to_string()],
+                    vec![Value::Integer(1), Value::Integer(2)]
+                )
+                .into()
+            )
+        );
+    }
+}
+
 /// Render microseconds-since-midnight as an ISO time string 
`HH:MM:SS[.ffffff]`.
 fn micros_to_time_string(micros: i64) -> String {
     let total_secs = micros.div_euclid(1_000_000);
@@ -2539,6 +2926,42 @@ mod conversion_tests {
         .unwrap()
     }
 
+    #[test]
+    fn decoded_rows_share_batch_field_names_and_preserve_values() {
+        use arrow::ipc::writer::StreamWriter;
+
+        let batch = sample_batch();
+        let mut data = Vec::new();
+        {
+            let mut writer = StreamWriter::try_new(&mut data, 
batch.schema().as_ref()).unwrap();
+            writer.write(&batch.slice(0, 0)).unwrap();
+            writer.write(&batch).unwrap();
+            writer.write(&batch).unwrap();
+            writer.finish().unwrap();
+        }
+        let response = proto::execute_plan_response::ArrowBatch {
+            data: data.into(),
+            ..Default::default()
+        };
+        let rows = decode_arrow_batch(&response).unwrap();
+
+        assert_eq!(rows.len(), 6);
+        for (index, row) in rows.iter().enumerate() {
+            let source_index = index % 3;
+            assert_eq!(row.fields(), &["id".to_string(), "name".to_string()]);
+            assert_eq!(row.get(0), Some(&Value::Long(source_index as i64 + 
1)));
+            assert_eq!(
+                row.get_by_name("name"),
+                Some(&Value::String(["a", "b", "c"][source_index].to_string()))
+            );
+        }
+        assert!(std::ptr::eq(rows[0].fields(), rows[1].fields()));
+        assert!(std::ptr::eq(rows[1].fields(), rows[2].fields()));
+        assert!(std::ptr::eq(rows[3].fields(), rows[4].fields()));
+        assert!(std::ptr::eq(rows[4].fields(), rows[5].fields()));
+        assert!(decode_arrow_batch(&Default::default()).unwrap().is_empty());
+    }
+
     #[test]
     fn to_arrow_ipc_round_trips() {
         use arrow::ipc::reader::FileReader;
diff --git a/crates/spark-connect/src/row.rs b/crates/spark-connect/src/row.rs
index 3a5837e..a4b3f52 100644
--- a/crates/spark-connect/src/row.rs
+++ b/crates/spark-connect/src/row.rs
@@ -5,9 +5,10 @@
 
 use std::collections::BTreeMap;
 use std::fmt;
+use std::sync::Arc;
 
 /// A Value in a Row, supporting all Spark SQL types.
-#[derive(Debug, Clone, PartialEq)]
+#[derive(Clone, PartialEq)]
 pub enum Value {
     /// NULL value
     Null,
@@ -43,14 +44,65 @@ pub enum Value {
     List(Vec<Value>),
     /// Map of key-value pairs
     Map(BTreeMap<String, Value>),
-    /// Struct (nested Row)
-    Struct(Vec<(String, Value)>),
+    /// Struct (nested Row), with ordered names and values.
+    /// Construct with `Value::Struct(Row::new(fields, values).into())`.
+    Struct(StructValue),
     /// A VARIANT value carried as its raw (value, metadata) binary 
components, matching
     /// `pyspark.sql.types.VariantVal(value, metadata)`. Decoding to 
JSON/Python is done
     /// lazily on the Python side (VariantVal.toJson/toPython via 
variant_utils).
     Variant { value: Vec<u8>, metadata: Vec<u8> },
 }
 
+impl fmt::Debug for Value {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        match self {
+            Self::Null => write!(f, "Null"),
+            Self::Bool(v) => f.debug_tuple("Bool").field(v).finish(),
+            Self::Byte(v) => f.debug_tuple("Byte").field(v).finish(),
+            Self::Short(v) => f.debug_tuple("Short").field(v).finish(),
+            Self::Integer(v) => f.debug_tuple("Integer").field(v).finish(),
+            Self::Long(v) => f.debug_tuple("Long").field(v).finish(),
+            Self::Float(v) => f.debug_tuple("Float").field(v).finish(),
+            Self::Double(v) => f.debug_tuple("Double").field(v).finish(),
+            Self::String(v) => f.debug_tuple("String").field(v).finish(),
+            Self::Binary(v) => f.debug_tuple("Binary").field(v).finish(),
+            Self::Date(v) => f.debug_tuple("Date").field(v).finish(),
+            Self::Timestamp(v) => f.debug_tuple("Timestamp").field(v).finish(),
+            Self::Decimal {
+                value,
+                precision,
+                scale,
+            } => f
+                .debug_struct("Decimal")
+                .field("value", value)
+                .field("precision", precision)
+                .field("scale", scale)
+                .finish(),
+            Self::List(v) => f.debug_tuple("List").field(v).finish(),
+            Self::Map(v) => f.debug_tuple("Map").field(v).finish(),
+            Self::Struct(row) => {
+                // Keep the previous ordered-pair representation while names 
are shared.
+                struct Fields<'a>(&'a StructValue);
+
+                impl fmt::Debug for Fields<'_> {
+                    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+                        f.debug_list()
+                            
.entries(self.0.fields().iter().zip(self.0.values()))
+                            .finish()
+                    }
+                }
+
+                f.debug_tuple("Struct").field(&Fields(row)).finish()
+            }
+            Self::Variant { value, metadata } => f
+                .debug_struct("Variant")
+                .field("value", value)
+                .field("metadata", metadata)
+                .finish(),
+        }
+    }
+}
+
 impl Value {
     /// Get this value as a bool, or None if it's not a bool.
     pub fn as_bool(&self) -> Option<bool> {
@@ -155,7 +207,7 @@ impl fmt::Display for Value {
             }
             Value::Struct(s) => {
                 write!(f, "(")?;
-                for (i, (k, v)) in s.iter().enumerate() {
+                for (i, (k, v)) in 
s.fields().iter().zip(s.values()).enumerate() {
                     if i > 0 {
                         write!(f, ", ")?;
                     }
@@ -175,16 +227,119 @@ impl fmt::Display for Value {
     }
 }
 
+/// An ordered struct value with shared immutable field names and owned values.
+/// Cloning shares the names while cloning each value independently.
+#[derive(Debug, Clone, PartialEq)]
+pub struct StructValue {
+    fields: Arc<[String]>,
+    values: Vec<Value>,
+}
+
+impl StructValue {
+    /// Create a struct with immutable names that can be shared with other 
structs.
+    /// Panics if the numbers of names and values differ.
+    pub fn new(fields: Arc<[String]>, values: Vec<Value>) -> Self {
+        assert_eq!(
+            fields.len(),
+            values.len(),
+            "field names and values must have the same length"
+        );
+        Self { fields, values }
+    }
+
+    /// Get the number of fields.
+    pub fn len(&self) -> usize {
+        self.fields.len()
+    }
+
+    /// Check whether this struct has no fields.
+    pub fn is_empty(&self) -> bool {
+        self.fields.is_empty()
+    }
+
+    /// Get the ordered field names.
+    pub fn fields(&self) -> &[String] {
+        &self.fields
+    }
+
+    /// Get the ordered values.
+    pub fn values(&self) -> &[Value] {
+        &self.values
+    }
+
+    /// Get a field value by index.
+    pub fn get(&self, index: usize) -> Option<&Value> {
+        self.values.get(index)
+    }
+
+    /// Get the first field value with the given name.
+    pub fn get_by_name(&self, name: &str) -> Option<&Value> {
+        self.fields
+            .iter()
+            .position(|field| field == name)
+            .and_then(|index| self.values.get(index))
+    }
+}
+
+#[derive(Clone)]
+enum RowFields {
+    Owned(Vec<String>),
+    Shared(Arc<[String]>),
+}
+
+impl RowFields {
+    fn as_slice(&self) -> &[String] {
+        match self {
+            Self::Owned(fields) => fields,
+            Self::Shared(fields) => fields,
+        }
+    }
+}
+
+impl fmt::Debug for RowFields {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        fmt::Debug::fmt(self.as_slice(), f)
+    }
+}
+
+impl PartialEq for RowFields {
+    fn eq(&self, other: &Self) -> bool {
+        self.as_slice() == other.as_slice()
+    }
+}
+
 /// A Row is an ordered collection of (field_name, value) pairs.
 /// Supports access by index or by field name.
 #[derive(Debug, Clone, PartialEq)]
 pub struct Row {
     /// Field names (in order)
-    fields: Vec<String>,
+    fields: RowFields,
     /// Values corresponding to fields (in order)
     values: Vec<Value>,
 }
 
+impl From<Row> for StructValue {
+    fn from(row: Row) -> Self {
+        let fields = match row.fields {
+            RowFields::Owned(fields) => fields.into(),
+            RowFields::Shared(fields) => fields,
+        };
+        Self {
+            fields,
+            values: row.values,
+        }
+    }
+}
+
+impl From<StructValue> for Row {
+    fn from(value: StructValue) -> Self {
+        Self {
+            fields: RowFields::Shared(value.fields),
+            values: value.values,
+        }
+    }
+}
+
 impl Row {
     /// Create a new Row from field names and values.
     /// Panics if lengths don't match.
@@ -194,25 +349,41 @@ impl Row {
             values.len(),
             "field names and values must have the same length"
         );
-        Row { fields, values }
+        Row {
+            fields: RowFields::Owned(fields),
+            values,
+        }
+    }
+
+    /// Create a Row with immutable field names shared by rows from the same 
batch.
+    pub(crate) fn with_shared_fields(fields: Arc<[String]>, values: 
Vec<Value>) -> Self {
+        assert_eq!(
+            fields.len(),
+            values.len(),
+            "field names and values must have the same length"
+        );
+        Row {
+            fields: RowFields::Shared(fields),
+            values,
+        }
     }
 
     /// Create an empty Row.
     pub fn empty() -> Self {
         Row {
-            fields: vec![],
+            fields: RowFields::Owned(vec![]),
             values: vec![],
         }
     }
 
     /// Get the number of fields in this Row.
     pub fn len(&self) -> usize {
-        self.fields.len()
+        self.fields().len()
     }
 
     /// Check if this Row is empty.
     pub fn is_empty(&self) -> bool {
-        self.fields.is_empty()
+        self.fields().is_empty()
     }
 
     /// Get a field value by index.
@@ -227,7 +398,7 @@ impl Row {
 
     /// Get a field value by name.
     pub fn get_by_name(&self, name: &str) -> Option<&Value> {
-        self.fields
+        self.fields()
             .iter()
             .position(|f| f == name)
             .and_then(|i| self.values.get(i))
@@ -235,7 +406,7 @@ impl Row {
 
     /// Get field names.
     pub fn fields(&self) -> &[String] {
-        &self.fields
+        self.fields.as_slice()
     }
 
     /// Get values.
@@ -293,6 +464,234 @@ mod tests {
         assert_eq!(row.get_by_name("nonexistent"), None);
     }
 
+    #[test]
+    fn cloned_rows_share_field_names_and_keep_values_independent() {
+        let fields: Arc<[String]> = vec!["items".to_string()].into();
+        let row = Row::with_shared_fields(
+            Arc::clone(&fields),
+            vec![Value::List(vec![Value::String("original".to_string())])],
+        );
+        let mut cloned = row.clone();
+
+        assert!(std::ptr::eq(fields.as_ref(), row.fields()));
+        assert!(std::ptr::eq(row.fields(), cloned.fields()));
+        let Value::List(items) = &mut cloned.values[0] else {
+            panic!("expected a list value");
+        };
+        items[0] = Value::String("changed".to_string());
+        assert_eq!(
+            row.get(0),
+            Some(&Value::List(vec![Value::String("original".to_string())]))
+        );
+        assert_ne!(row.values(), cloned.values());
+    }
+
+    #[test]
+    fn constructed_rows_clone_values_independently() {
+        let row = Row::new(
+            vec!["items".to_string()],
+            vec![Value::List(vec![Value::String("original".to_string())])],
+        );
+
+        let mut cloned = row.clone();
+        assert_eq!(row.fields(), cloned.fields());
+        let Value::List(items) = &mut cloned.values[0] else {
+            panic!("expected a list value");
+        };
+        items[0] = Value::String("changed".to_string());
+        assert_eq!(
+            row.get(0),
+            Some(&Value::List(vec![Value::String("original".to_string())]))
+        );
+        assert_ne!(row.values(), cloned.values());
+    }
+
+    #[test]
+    fn shared_field_names_preserve_order_duplicates_equality_and_debug() {
+        let names = vec![
+            "second".to_string(),
+            "first".to_string(),
+            "second".to_string(),
+        ];
+        let values = vec![Value::Long(2), Value::Long(1), Value::Long(3)];
+        let shared = Row::with_shared_fields(names.clone().into(), 
values.clone());
+        let owned = Row::new(names.clone(), values);
+
+        assert_eq!(shared.fields(), names.as_slice());
+        assert_eq!(shared.get_by_name("second"), Some(&Value::Long(2)));
+        assert_eq!(shared.get(2), Some(&Value::Long(3)));
+        assert_eq!(shared, owned);
+        assert_eq!(owned, shared);
+        assert_eq!(format!("{shared:?}"), format!("{owned:?}"));
+        assert_eq!(
+            format!("{shared:?}"),
+            "Row { fields: [\"second\", \"first\", \"second\"], values: 
[Long(2), Long(1), Long(3)] }"
+        );
+        let reordered = Row::new(
+            vec![
+                "first".to_string(),
+                "second".to_string(),
+                "second".to_string(),
+            ],
+            owned.values().to_vec(),
+        );
+        assert_ne!(shared, reordered);
+    }
+
+    #[test]
+    fn nested_struct_names_preserve_pair_order_equality_and_formatting() {
+        let names = vec![
+            "".to_string(),
+            "a".to_string(),
+            "a".to_string(),
+            "é".to_string(),
+        ];
+        let values = vec![
+            Value::Null,
+            Value::Long(1),
+            Value::Long(2),
+            Value::List(vec![Value::String("original".to_string())]),
+        ];
+        let shared =
+            Value::Struct(Row::with_shared_fields(names.clone().into(), 
values.clone()).into());
+        let owned = Value::Struct(Row::new(names, values).into());
+        assert_eq!(shared, owned);
+        assert_eq!(owned, shared);
+        let Value::Struct(row) = &shared else {
+            panic!("expected a struct value");
+        };
+        assert_eq!(row.get_by_name("a"), Some(&Value::Long(1)));
+        assert_eq!(row.get(2), Some(&Value::Long(2)));
+        assert_eq!(shared.to_string(), "(=NULL, a=1, a=2, é=[original])");
+        assert_eq!(
+            format!("{shared:?}"),
+            "Struct([(\"\", Null), (\"a\", Long(1)), (\"a\", Long(2)), (\"é\", 
List([String(\"original\")]))])"
+        );
+        assert_eq!(format!("{shared:#?}"), format!("{owned:#?}"));
+        let empty = Value::Struct(Row::empty().into());
+        assert_eq!(format!("{empty:?}"), "Struct([])");
+        assert_eq!(empty.to_string(), "()");
+        let one = Value::Struct(Row::new(vec!["a".to_string()], 
vec![Value::Integer(1)]).into());
+        assert_eq!(
+            format!("{one:#?}"),
+            concat!(
+                "Struct(\n",
+                "    [\n",
+                "        (\n",
+                "            \"a\",\n",
+                "            Integer(\n",
+                "                1,\n",
+                "            ),\n",
+                "        ),\n",
+                "    ],\n",
+                ")"
+            )
+        );
+    }
+
+    #[test]
+    fn nested_struct_clones_share_names_and_keep_child_values_independent() {
+        let fields: Arc<[String]> = vec!["items".to_string()].into();
+        let value = Value::Struct(
+            Row::with_shared_fields(
+                Arc::clone(&fields),
+                vec![Value::List(vec![Value::String("original".to_string())])],
+            )
+            .into(),
+        );
+        let mut cloned = value.clone();
+        let Value::Struct(original_row) = &value else {
+            panic!("expected a struct value");
+        };
+        let Value::Struct(cloned_row) = &mut cloned else {
+            panic!("expected a cloned struct value");
+        };
+        assert!(std::ptr::eq(fields.as_ref(), cloned_row.fields()));
+        assert!(std::ptr::eq(original_row.fields(), cloned_row.fields()));
+        let Value::List(items) = &mut cloned_row.values[0] else {
+            panic!("expected a list value");
+        };
+        items[0] = Value::String("changed".to_string());
+        assert_eq!(
+            original_row.get(0),
+            Some(&Value::List(vec![Value::String("original".to_string())]))
+        );
+        assert_ne!(value, cloned);
+    }
+
+    #[test]
+    fn nested_struct_equality_does_not_shortcut_nan() {
+        let value = Value::Struct(
+            Row::with_shared_fields(
+                vec!["nan".to_string()].into(),
+                vec![Value::Double(f64::NAN)],
+            )
+            .into(),
+        );
+        assert_ne!(value, value.clone());
+    }
+
+    #[test]
+    fn struct_row_conversions_move_values_and_preserve_name_storage() {
+        let row = Row::new(
+            vec!["field".to_string()],
+            vec![Value::String("value".to_string())],
+        );
+        let names_text = row.fields()[0].as_ptr();
+        let values = row.values().as_ptr();
+        let value = StructValue::from(row);
+        assert_eq!(value.fields()[0].as_ptr(), names_text);
+        assert_eq!(value.values().as_ptr(), values);
+        assert_eq!(value.len(), 1);
+        assert!(!value.is_empty());
+        assert_eq!(value.get(0), Some(&Value::String("value".to_string())));
+        assert_eq!(value.get(1), None);
+        assert_eq!(value.get_by_name("missing"), None);
+
+        let names = value.fields().as_ptr();
+        let row = Row::from(value);
+        assert_eq!(row.fields().as_ptr(), names);
+        assert_eq!(row.values().as_ptr(), values);
+        let value = StructValue::from(row);
+        assert_eq!(value.fields().as_ptr(), names);
+        assert_eq!(value.values().as_ptr(), values);
+    }
+
+    #[test]
+    fn struct_value_constructor_shares_names_and_preserves_empty_values() {
+        let names: Arc<[String]> = vec!["name".to_string()].into();
+        let value = StructValue::new(Arc::clone(&names), vec![Value::Null]);
+        assert!(std::ptr::eq(names.as_ref(), value.fields()));
+        assert_eq!(value.get_by_name("name"), Some(&Value::Null));
+        let empty = StructValue::new(Vec::new().into(), vec![]);
+        assert_eq!(empty.len(), 0);
+        assert!(empty.is_empty());
+        assert!(empty.fields().is_empty());
+        assert!(empty.values().is_empty());
+        assert_eq!(Row::from(empty), Row::empty());
+    }
+
+    #[test]
+    #[should_panic(expected = "field names and values must have the same 
length")]
+    fn struct_value_constructor_rejects_mismatched_lengths() {
+        let _ = StructValue::new(vec!["name".to_string()].into(), vec![]);
+    }
+
+    #[test]
+    fn shared_empty_field_names_preserve_empty_row_behavior() {
+        let fields: Arc<[String]> = Vec::new().into();
+        let row = Row::with_shared_fields(fields, vec![]);
+        assert_eq!(row, Row::empty());
+        assert!(row.fields().is_empty());
+        assert!(row.into_values().is_empty());
+    }
+
+    #[test]
+    #[should_panic(expected = "field names and values must have the same 
length")]
+    fn shared_field_names_reject_mismatched_lengths() {
+        let _ = Row::with_shared_fields(vec!["field".to_string()].into(), 
vec![]);
+    }
+
     #[test]
     fn test_value_conversions() {
         let b = Value::Bool(true);
@@ -393,7 +792,8 @@ mod tests {
         m.insert("k".to_string(), Value::Integer(9));
         assert_eq!(Value::Map(m).to_string(), "{k: 9}");
         assert_eq!(
-            Value::Struct(vec![("a".to_string(), 
Value::Integer(1))]).to_string(),
+            Value::Struct(Row::new(vec!["a".to_string()], 
vec![Value::Integer(1)]).into())
+                .to_string(),
             "(a=1)"
         );
     }
diff --git a/crates/spark-connect/src/session.rs 
b/crates/spark-connect/src/session.rs
index eff2adf..30a31a8 100644
--- a/crates/spark-connect/src/session.rs
+++ b/crates/spark-connect/src/session.rs
@@ -781,18 +781,18 @@ fn coerce_value(v: Option<&Value>, target: &DataType) -> 
Value {
                 .collect(),
         ),
         (DataType::Struct { fields }, Value::Struct(sf)) => Value::Struct(
-            fields
-                .iter()
-                .enumerate()
-                .map(|(i, f)| {
-                    let val = sf
-                        .iter()
-                        .find(|(n, _)| n == &f.name)
-                        .map(|(_, v)| v)
-                        .or_else(|| sf.get(i).map(|(_, v)| v));
-                    (f.name.clone(), coerce_value(val, &f.data_type))
-                })
-                .collect(),
+            Row::new(
+                fields.iter().map(|f| f.name.clone()).collect(),
+                fields
+                    .iter()
+                    .enumerate()
+                    .map(|(i, f)| {
+                        let val = sf.get_by_name(&f.name).or_else(|| 
sf.get(i));
+                        coerce_value(val, &f.data_type)
+                    })
+                    .collect(),
+            )
+            .into(),
         ),
         (DataType::Map { value_type, .. }, Value::Map(m)) => Value::Map(
             m.iter()
@@ -1002,10 +1002,9 @@ fn values_to_arrow(values: &[Value], dt: &DataType) -> 
Result<Arc<dyn arrow::arr
                         for (i, f) in fields.iter().enumerate() {
                             // Prefer match by field name, else positional.
                             let val = sf
-                                .iter()
-                                .find(|(n, _)| n == &f.name)
-                                .map(|(_, val)| val.clone())
-                                .or_else(|| sf.get(i).map(|(_, val)| 
val.clone()))
+                                .get_by_name(&f.name)
+                                .or_else(|| sf.get(i))
+                                .cloned()
                                 .unwrap_or(Value::Null);
                             cols[i].push(val);
                         }
@@ -1302,6 +1301,238 @@ fn decimal_str_to_unscaled(s: &str, scale: i32) -> 
Result<i128> {
 mod tests {
     use super::*;
 
+    fn struct_field(name: &str, data_type: DataType) -> 
crate::types::StructField {
+        crate::types::StructField {
+            name: name.to_string(),
+            data_type,
+            nullable: true,
+            metadata: Default::default(),
+        }
+    }
+
+    #[test]
+    fn 
nested_struct_coercion_preserves_first_name_position_and_numeric_types() {
+        let source = Value::Struct(
+            Row::with_shared_fields(
+                vec!["dup", "dup", "other", "nested", "items", "map"]
+                    .into_iter()
+                    .map(str::to_string)
+                    .collect::<Arc<[String]>>(),
+                vec![
+                    Value::Long(257),
+                    Value::Long(999),
+                    Value::Long(-2),
+                    Value::Struct(Row::new(vec!["n".to_string()], 
vec![Value::Long(300)]).into()),
+                    Value::List(vec![Value::Long(65_537), Value::Null]),
+                    Value::Map([("k".to_string(), 
Value::Long(65_538))].into()),
+                ],
+            )
+            .into(),
+        );
+        let target = DataType::Struct {
+            fields: vec![
+                struct_field("dup", DataType::Byte),
+                struct_field("positional", DataType::Short),
+                struct_field("dup", DataType::Long),
+                struct_field(
+                    "nested",
+                    DataType::Struct {
+                        fields: vec![struct_field("n", DataType::Byte)],
+                    },
+                ),
+                struct_field(
+                    "items",
+                    DataType::Array {
+                        element_type: Box::new(DataType::Short),
+                        contains_null: true,
+                    },
+                ),
+                struct_field(
+                    "map",
+                    DataType::Map {
+                        key_type: Box::new(DataType::String {
+                            collation: "UTF8_BINARY".to_string(),
+                        }),
+                        value_type: Box::new(DataType::Short),
+                        value_contains_null: true,
+                    },
+                ),
+                struct_field("missing", DataType::Integer),
+            ],
+        };
+        let actual = coerce_value(Some(&source), &target);
+        let expected = Value::Struct(
+            Row::new(
+                vec![
+                    "dup",
+                    "positional",
+                    "dup",
+                    "nested",
+                    "items",
+                    "map",
+                    "missing",
+                ]
+                .into_iter()
+                .map(str::to_string)
+                .collect(),
+                vec![
+                    Value::Byte(1),
+                    Value::Short(999),
+                    Value::Long(257),
+                    Value::Struct(Row::new(vec!["n".to_string()], 
vec![Value::Byte(44)]).into()),
+                    Value::List(vec![Value::Short(1), Value::Null]),
+                    Value::Map([("k".to_string(), Value::Short(2))].into()),
+                    Value::Null,
+                ],
+            )
+            .into(),
+        );
+        assert_eq!(actual, expected);
+        assert_eq!(coerce_value(Some(&Value::Null), &target), Value::Null);
+        assert_eq!(coerce_value(None, &target), Value::Null);
+    }
+
+    #[test]
+    fn struct_arrow_encoding_preserves_lookup_map_and_null_semantics() {
+        use arrow::array::{Array, Int16Array, Int32Array, Int64Array, 
StructArray};
+
+        let target = DataType::Struct {
+            fields: vec![
+                struct_field("dup", DataType::Integer),
+                struct_field("positional", DataType::Short),
+                struct_field("dup", DataType::Long),
+                struct_field("missing", DataType::Integer),
+            ],
+        };
+        let values = vec![
+            Value::Struct(
+                Row::new(
+                    vec!["dup".to_string(), "dup".to_string(), 
"other".to_string()],
+                    vec![Value::Long(257), Value::Long(999), Value::Long(-2)],
+                )
+                .into(),
+            ),
+            Value::Map(
+                [
+                    ("dup".to_string(), Value::Long(5)),
+                    ("other".to_string(), Value::Long(9)),
+                ]
+                .into(),
+            ),
+            Value::Null,
+            Value::Struct(Row::new(vec!["dup".to_string()], 
vec![Value::Null]).into()),
+        ];
+        let array = values_to_arrow(&values, &target).unwrap();
+        let array = array.as_any().downcast_ref::<StructArray>().unwrap();
+        assert_eq!(array.len(), 4);
+        assert!(!array.is_null(0));
+        assert!(!array.is_null(1));
+        assert!(array.is_null(2));
+        assert!(!array.is_null(3));
+        let first = array
+            .column(0)
+            .as_any()
+            .downcast_ref::<Int32Array>()
+            .unwrap();
+        assert_eq!(
+            first,
+            &Int32Array::from(vec![Some(257), Some(5), None, None])
+        );
+        let positional = array
+            .column(1)
+            .as_any()
+            .downcast_ref::<Int16Array>()
+            .unwrap();
+        assert_eq!(
+            positional,
+            &Int16Array::from(vec![Some(999), None, None, None])
+        );
+        let duplicate = array
+            .column(2)
+            .as_any()
+            .downcast_ref::<Int64Array>()
+            .unwrap();
+        assert_eq!(
+            duplicate,
+            &Int64Array::from(vec![Some(257), Some(5), None, None])
+        );
+        assert_eq!(array.column(3).null_count(), 4);
+    }
+
+    #[test]
+    fn struct_arrow_encoding_recurses_through_arrays_and_maps() {
+        use arrow::array::{Int16Array, ListArray, MapArray, StructArray};
+
+        let leaf_type = DataType::Struct {
+            fields: vec![struct_field("n", DataType::Short)],
+        };
+        let leaf_value =
+            || Value::Struct(Row::new(vec!["n".to_string()], 
vec![Value::Long(300)]).into());
+        let target = DataType::Struct {
+            fields: vec![
+                struct_field(
+                    "items",
+                    DataType::Array {
+                        element_type: Box::new(leaf_type.clone()),
+                        contains_null: true,
+                    },
+                ),
+                struct_field(
+                    "lookup",
+                    DataType::Map {
+                        key_type: Box::new(DataType::String {
+                            collation: "UTF8_BINARY".to_string(),
+                        }),
+                        value_type: Box::new(leaf_type),
+                        value_contains_null: true,
+                    },
+                ),
+            ],
+        };
+        let source = Value::Struct(
+            Row::new(
+                vec!["items".to_string(), "lookup".to_string()],
+                vec![
+                    Value::List(vec![leaf_value(), Value::Null]),
+                    Value::Map([("k".to_string(), leaf_value())].into()),
+                ],
+            )
+            .into(),
+        );
+        let array = values_to_arrow(&[source], &target).unwrap();
+        let array = array.as_any().downcast_ref::<StructArray>().unwrap();
+        let items = array
+            .column(0)
+            .as_any()
+            .downcast_ref::<ListArray>()
+            .unwrap();
+        let items = items.value(0);
+        let items = items.as_any().downcast_ref::<StructArray>().unwrap();
+        assert_eq!(
+            items
+                .column(0)
+                .as_any()
+                .downcast_ref::<Int16Array>()
+                .unwrap(),
+            &Int16Array::from(vec![Some(300), None])
+        );
+        let lookup = 
array.column(1).as_any().downcast_ref::<MapArray>().unwrap();
+        let entries = lookup.value(0);
+        let value = entries
+            .column(1)
+            .as_any()
+            .downcast_ref::<StructArray>()
+            .unwrap();
+        assert_eq!(
+            value
+                .column(0)
+                .as_any()
+                .downcast_ref::<Int16Array>()
+                .unwrap(),
+            &Int16Array::from(vec![Some(300)])
+        );
+    }
+
     fn session() -> SparkSession {
         SparkSession::builder()
             .remote("sc://localhost:15002")


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to