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 f3914d2  [SPARK-59116] Complete PySpark 4.2.0 behavioral parity for 
the Rust drop-in
f3914d2 is described below

commit f3914d2ae83528b326a1b6cba10085122386984b
Author: Hyukjin Kwon <[email protected]>
AuthorDate: Sun Aug 30 07:11:00 2026 +0900

    [SPARK-59116] Complete PySpark 4.2.0 behavioral parity for the Rust drop-in
    
    Drove an exhaustive differential harness (real PySpark 4.2.0 Connect client 
vs
    the Rust drop-in, same ops on the same live server) to zero: 143/143 
identical.
    The native Rust engine (crates/spark-connect) was already correct; these 
were
    binding-layer gaps (crates/pyspark-rs + python/pyspark) plus a few core
    function-signature extensions.
    
    - na.fill/fillna accept float/string/bool and the {col: value} dict form 
(were
      int-only); single-field DDL schema strings parse as a schema; 
createDataFrame
      accepts empty data with an explicit schema.
    - Window subsystem: Window.partitionBy/orderBy coerced a bare string to a
      literal (so windows partitioned/ordered by a constant), and the ROWS frame
      boundary had the wrong type -> row_number/rank/dense_rank/cume_dist/
      percent_rank/lag/lead/nth_value and sum over rows/rangeBetween now 
correct.
    - Chained when().when().otherwise() (the free function built an
      UnresolvedFunction instead of a CaseWhen, dropping earlier branches).
    - lag/lead accept offset/default; json_tuple is variadic over literal field 
names.
    - Parameterized spark.sql(query, args=[...] | {...}); struct/named_struct/
      from_json return a Row (not a dict) for struct-typed values; 
createDataFrame
      with array<struct> data.
    
    Verified: differential harness 143/143 (zero divergences), three behavioral
    batteries 32/32 + 83/83 + 56/56, offline suite 236 passed, cargo fmt/build 
clean,
    ruff clean.
    
    Co-authored-by: Isaac <[email protected]>
---
 crates/pyspark-rs/src/dataframe.rs             | 26 ++++++--
 crates/pyspark-rs/src/dispatch_generated.rs    | 15 +----
 crates/pyspark-rs/src/row.rs                   | 13 ++--
 crates/pyspark-rs/src/session.rs               | 52 ++++++++++++----
 crates/pyspark-rs/src/window.rs                | 75 ++++++++++-------------
 crates/spark-connect/src/expression.rs         |  6 +-
 crates/spark-connect/src/functions.rs          | 29 +++++----
 crates/spark-connect/src/plan.rs               | 22 ++++++-
 crates/spark-connect/src/session.rs            | 83 ++++++++++++++++++++++----
 crates/spark-connect/src/types.rs              | 32 ++++++++--
 crates/spark-connect/tests/functions_golden.rs |  6 +-
 crates/spark-connect/tests/window_golden.rs    |  4 +-
 python/pyspark/sql/functions.py                | 21 +++++++
 python/tests/test_dropin_offline.py            |  4 ++
 14 files changed, 276 insertions(+), 112 deletions(-)

diff --git a/crates/pyspark-rs/src/dataframe.rs 
b/crates/pyspark-rs/src/dataframe.rs
index 5840d18..00be354 100644
--- a/crates/pyspark-rs/src/dataframe.rs
+++ b/crates/pyspark-rs/src/dataframe.rs
@@ -1346,6 +1346,7 @@ impl PyDataFrame {
     fn na(&self) -> PyDataFrameNaFunctions {
         PyDataFrameNaFunctions {
             inner: self.dataframe.na(),
+            df: self.dataframe.clone(),
         }
     }
 
@@ -1464,6 +1465,7 @@ impl PyDataFrame {
 #[pyclass(name = "DataFrameNaFunctions")]
 pub struct PyDataFrameNaFunctions {
     inner: spark_connect::group::NaFunctions,
+    df: spark_connect::dataframe::DataFrame,
 }
 
 #[pymethods]
@@ -1481,12 +1483,28 @@ impl PyDataFrameNaFunctions {
         PyDataFrame::new(self.inner.drop(how.as_deref(), thresh, subset_refs))
     }
 
+    /// Fill nulls; `value` may be a scalar (float / int / str / bool, 
optionally over
+    /// `subset`) or a {column: value} mapping. Alias of `DataFrame.fillna`. 
PySpark fills
+    /// only columns whose type matches the value's type (server-side).
     #[pyo3(signature = (value, subset=None))]
-    fn fill(&self, value: i64, subset: Option<Vec<String>>) -> PyDataFrame {
-        let subset_refs: Option<Vec<&str>> = subset
+    fn fill(
+        &self,
+        value: &Bound<'_, PyAny>,
+        subset: Option<&Bound<'_, PyAny>>,
+    ) -> PyResult<PyDataFrame> {
+        if let Ok(d) = value.cast::<PyDict>() {
+            let mut pairs = Vec::with_capacity(d.len());
+            for (k, v) in d.iter() {
+                pairs.push((k.extract::<String>()?, 
crate::session::py_to_value(&v)?));
+            }
+            return Ok(PyDataFrame::new(self.df.fillna_map(pairs)));
+        }
+        let val = crate::session::py_to_value(value)?;
+        let owned = to_subset(subset)?;
+        let refs: Option<Vec<&str>> = owned
             .as_ref()
-            .map(|v| v.iter().map(String::as_str).collect());
-        PyDataFrame::new(self.inner.fill(value, subset_refs))
+            .map(|v| v.iter().map(|s| s.as_str()).collect());
+        Ok(PyDataFrame::new(self.df.fillna_value(val, refs)))
     }
 
     /// Replace values, mirroring `DataFrameNaFunctions.replace`:
diff --git a/crates/pyspark-rs/src/dispatch_generated.rs 
b/crates/pyspark-rs/src/dispatch_generated.rs
index 0951bb0..6523d54 100644
--- a/crates/pyspark-rs/src/dispatch_generated.rs
+++ b/crates/pyspark-rs/src/dispatch_generated.rs
@@ -431,10 +431,7 @@ pub fn call_builtin(name: &str, args: 
Vec<spark_connect::column::Column>) -> PyR
                 if args.is_empty() { return 
Err(PyErr::new::<pyo3::exceptions::PyTypeError, _>("Missing required column 
argument")); }
                 Ok(spark_funcs::kurtosis(args[0].clone()))
             },
-            "lag" => {
-                if args.is_empty() { return 
Err(PyErr::new::<pyo3::exceptions::PyTypeError, _>("Missing required column 
argument")); }
-                Ok(spark_funcs::lag(args[0].clone()))
-            },
+            "lag" => Ok(spark_funcs::lag(args.clone())),
             "last" => {
                 if args.is_empty() { return 
Err(PyErr::new::<pyo3::exceptions::PyTypeError, _>("Missing required column 
argument")); }
                 Ok(spark_funcs::last(args[0].clone()))
@@ -451,10 +448,7 @@ pub fn call_builtin(name: &str, args: 
Vec<spark_connect::column::Column>) -> PyR
                 if args.is_empty() { return 
Err(PyErr::new::<pyo3::exceptions::PyTypeError, _>("Missing required column 
argument")); }
                 Ok(spark_funcs::lcase(args[0].clone()))
             },
-            "lead" => {
-                if args.is_empty() { return 
Err(PyErr::new::<pyo3::exceptions::PyTypeError, _>("Missing required column 
argument")); }
-                Ok(spark_funcs::lead(args[0].clone()))
-            },
+            "lead" => Ok(spark_funcs::lead(args.clone())),
             "length" => {
                 if args.is_empty() { return 
Err(PyErr::new::<pyo3::exceptions::PyTypeError, _>("Missing required column 
argument")); }
                 Ok(spark_funcs::length(args[0].clone()))
@@ -1371,10 +1365,7 @@ pub fn call_builtin(name: &str, args: 
Vec<spark_connect::column::Column>) -> PyR
                 if args.len() < 2 { return 
Err(PyErr::new::<pyo3::exceptions::PyTypeError, _>(format!("Missing required 
arguments for instr: expected at least 2, got {}", args.len()))); }
                 Ok(spark_funcs::instr(args[0].clone(), args[1].clone()))
             },
-            "json_tuple" => {
-                if args.len() < 2 { return 
Err(PyErr::new::<pyo3::exceptions::PyTypeError, _>(format!("Missing required 
arguments for json_tuple: expected at least 2, got {}", args.len()))); }
-                Ok(spark_funcs::json_tuple(args[0].clone(), args[1].clone()))
-            },
+            "json_tuple" => Ok(spark_funcs::json_tuple(args.clone())),
             "least" => {
                 if args.len() < 2 { return 
Err(PyErr::new::<pyo3::exceptions::PyTypeError, _>(format!("Missing required 
arguments for least: expected at least 2, got {}", args.len()))); }
                 Ok(spark_funcs::least(args[0].clone(), args[1].clone()))
diff --git a/crates/pyspark-rs/src/row.rs b/crates/pyspark-rs/src/row.rs
index 9387a62..4f4b4da 100644
--- a/crates/pyspark-rs/src/row.rs
+++ b/crates/pyspark-rs/src/row.rs
@@ -71,11 +71,16 @@ pub fn value_to_py<'py>(py: Python<'py>, v: &Value) -> 
PyResult<Bound<'py, PyAny
             dict.into_any()
         }
         Value::Struct(fields) => {
-            let dict = PyDict::new(py);
-            for (k, val) in fields {
-                dict.set_item(k, value_to_py(py, val)?)?;
+            // 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());
             }
-            dict.into_any()
+            Py::new(py, PyRow::new(spark_connect::row::Row::new(names, vals)))?
+                .into_bound(py)
+                .into_any()
         }
         Value::Variant { value, metadata } => {
             // A VARIANT materializes as a VariantVal (toJson/toPython decode 
lazily),
diff --git a/crates/pyspark-rs/src/session.rs b/crates/pyspark-rs/src/session.rs
index 3445c2a..e319015 100644
--- a/crates/pyspark-rs/src/session.rs
+++ b/crates/pyspark-rs/src/session.rs
@@ -244,9 +244,40 @@ impl PySparkSession {
         Ok(PyDataFrame::new(df))
     }
 
-    /// Execute a SQL query and return a DataFrame.
-    fn sql(&self, sqlQuery: &str) -> PyResult<PyDataFrame> {
-        let df = self.session.sql(sqlQuery).to_pyerr()?;
+    /// Execute a SQL query and return a DataFrame. `args` binds parameters: a 
list fills
+    /// positional `?` parameters, a dict fills named (`:name`) parameters. 
Values are always
+    /// literals (a string arg is a string literal, never a column reference).
+    #[pyo3(signature = (sqlQuery, args=None))]
+    fn sql(&self, sqlQuery: &str, args: Option<Bound<'_, PyAny>>) -> 
PyResult<PyDataFrame> {
+        fn to_lit(v: &Bound<'_, PyAny>) -> 
PyResult<spark_connect::expression::Expression> {
+            use spark_connect::expression::{Expression, LiteralExpression};
+            if let Ok(sv) = v.extract::<String>() {
+                return Ok(Expression::Literal(LiteralExpression::string(sv)));
+            }
+            Ok(crate::functions::to_column(v)?.expression().clone())
+        }
+        let mut pos: Vec<spark_connect::expression::Expression> = Vec::new();
+        let mut named: std::collections::HashMap<String, 
spark_connect::expression::Expression> =
+            std::collections::HashMap::new();
+        if let Some(a) = args {
+            if let Ok(d) = a.cast::<PyDict>() {
+                for (k, v) in d.iter() {
+                    named.insert(k.extract::<String>()?, to_lit(&v)?);
+                }
+            } else if let Ok(lst) = a.cast::<PyList>() {
+                for v in lst.iter() {
+                    pos.push(to_lit(&v)?);
+                }
+            } else {
+                return Err(PyErr::new::<pyo3::exceptions::PyTypeError, _>(
+                    "args must be a list (positional) or dict (named)",
+                ));
+            }
+        }
+        let df = self
+            .session
+            .sql_with_args(sqlQuery, pos, named)
+            .to_pyerr()?;
         Ok(PyDataFrame::new(df))
     }
 
@@ -375,16 +406,17 @@ impl PySparkSession {
             rows.push(Row::new(names, values));
         }
 
-        if rows.is_empty() {
-            return Err(PyErr::new::<pyo3::exceptions::PyValueError, _>(
-                "Data is empty",
-            ));
-        }
-
-        // An explicit struct schema wins; otherwise infer field types from 
the first row.
+        // An explicit struct schema wins; otherwise infer field types from 
the first row
+        // (which requires at least one row -- an empty dataset needs an 
explicit schema,
+        // matching pyspark which creates an empty DataFrame from schema + [] 
data).
         let schema_dtype = match spec {
             Spec::Struct(fields) => DataType::Struct { fields },
             _ => {
+                if rows.is_empty() {
+                    return Err(PyErr::new::<pyo3::exceptions::PyValueError, _>(
+                        "can not infer schema from empty dataset",
+                    ));
+                }
                 let fields: Vec<StructField> = rows[0]
                     .fields()
                     .iter()
diff --git a/crates/pyspark-rs/src/window.rs b/crates/pyspark-rs/src/window.rs
index 429fc6b..c81aef3 100644
--- a/crates/pyspark-rs/src/window.rs
+++ b/crates/pyspark-rs/src/window.rs
@@ -4,7 +4,34 @@ use pyo3::prelude::*;
 use spark_connect::expression::{Expression, NullOrdering, SortOrder};
 use spark_connect::window::{FrameBound, WindowSpec};
 
-use crate::functions::to_column;
+use crate::dataframe::to_column_list;
+
+/// Build partition expressions from ColumnOrName args (a bare string is a 
COLUMN
+/// NAME, not a literal — mirrors pyspark Window.partitionBy).
+fn to_partition_exprs(cols: Vec<Bound<'_, PyAny>>) -> 
PyResult<Vec<Expression>> {
+    Ok(to_column_list(cols)?
+        .iter()
+        .map(|c| c.expression().clone())
+        .collect())
+}
+
+/// Build sort orders from ColumnOrName args; a bare string becomes an 
ascending
+/// SortOrder over that column (not a string literal).
+fn to_sort_orders(cols: Vec<Bound<'_, PyAny>>) -> PyResult<Vec<SortOrder>> {
+    let mut sort_orders = Vec::new();
+    for column in to_column_list(cols)? {
+        if let Expression::SortOrder(sort) = column.expression() {
+            sort_orders.push((**sort).clone());
+        } else {
+            sort_orders.push(SortOrder {
+                child: column.expression().clone(),
+                ascending: true,
+                null_ordering: NullOrdering::Last,
+            });
+        }
+    }
+    Ok(sort_orders)
+}
 
 /// Python wrapper for a window frame bound.
 #[pyclass(name = "FrameBound", from_py_object)]
@@ -72,33 +99,14 @@ impl PyWindowSpec {
     /// Partition the window by the given columns.
     #[pyo3(signature = (*cols))]
     fn partitionBy(&self, cols: Vec<Bound<'_, PyAny>>) -> 
PyResult<PyWindowSpec> {
-        let mut expressions = Vec::new();
-        for col in cols {
-            expressions.push(to_column(&col)?.expression().clone());
-        }
-        let new_spec = self.spec.clone().partition_by(expressions);
+        let new_spec = 
self.spec.clone().partition_by(to_partition_exprs(cols)?);
         Ok(PyWindowSpec::new(new_spec))
     }
 
     /// Order the window by the given columns.
     #[pyo3(signature = (*cols))]
     fn orderBy(&self, cols: Vec<Bound<'_, PyAny>>) -> PyResult<PyWindowSpec> {
-        let mut sort_orders = Vec::new();
-        for col in cols {
-            let column = to_column(&col)?;
-            // Extract sort order from the column
-            if let Expression::SortOrder(sort) = column.expression() {
-                sort_orders.push((**sort).clone());
-            } else {
-                // Default to ascending
-                sort_orders.push(SortOrder {
-                    child: column.expression().clone(),
-                    ascending: true,
-                    null_ordering: NullOrdering::Last,
-                });
-            }
-        }
-        let new_spec = self.spec.clone().order_by(sort_orders);
+        let new_spec = self.spec.clone().order_by(to_sort_orders(cols)?);
         Ok(PyWindowSpec::new(new_spec))
     }
 
@@ -161,11 +169,7 @@ impl PyWindow {
     #[pyo3(signature = (*cols))]
     #[staticmethod]
     fn partitionBy(cols: Vec<Bound<'_, PyAny>>) -> PyResult<PyWindowSpec> {
-        let mut expressions = Vec::new();
-        for col in cols {
-            expressions.push(to_column(&col)?.expression().clone());
-        }
-        let spec = WindowSpec::new().partition_by(expressions);
+        let spec = WindowSpec::new().partition_by(to_partition_exprs(cols)?);
         Ok(PyWindowSpec::new(spec))
     }
 
@@ -173,22 +177,7 @@ impl PyWindow {
     #[pyo3(signature = (*cols))]
     #[staticmethod]
     fn orderBy(cols: Vec<Bound<'_, PyAny>>) -> PyResult<PyWindowSpec> {
-        let mut sort_orders = Vec::new();
-        for col in cols {
-            let column = to_column(&col)?;
-            // Extract sort order from the column
-            if let Expression::SortOrder(sort) = column.expression() {
-                sort_orders.push((**sort).clone());
-            } else {
-                // Default to ascending
-                sort_orders.push(SortOrder {
-                    child: column.expression().clone(),
-                    ascending: true,
-                    null_ordering: NullOrdering::Last,
-                });
-            }
-        }
-        let spec = WindowSpec::new().order_by(sort_orders);
+        let spec = WindowSpec::new().order_by(to_sort_orders(cols)?);
         Ok(PyWindowSpec::new(spec))
     }
 
diff --git a/crates/spark-connect/src/expression.rs 
b/crates/spark-connect/src/expression.rs
index 29ca479..b19947d 100644
--- a/crates/spark-connect/src/expression.rs
+++ b/crates/spark-connect/src/expression.rs
@@ -866,14 +866,16 @@ fn to_proto_frame_boundary(
         FrameBoundary::CurrentRow => Some(
             
proto::expression::window::window_frame::frame_boundary::Boundary::CurrentRow(true),
         ),
+        // A ROWS-frame offset must be an IntegerType literal (Spark rejects 
bigint with
+        // SPECIFIED_WINDOW_FRAME_UNACCEPTED_TYPE), and preceding is a 
negative offset.
         FrameBoundary::Preceding(n) => Some(
             
proto::expression::window::window_frame::frame_boundary::Boundary::Value(Box::new(
-                Expression::Literal(LiteralExpression::long(*n)).to_proto(),
+                Expression::Literal(LiteralExpression::int(-(*n as 
i32))).to_proto(),
             )),
         ),
         FrameBoundary::Following(n) => Some(
             
proto::expression::window::window_frame::frame_boundary::Boundary::Value(Box::new(
-                Expression::Literal(LiteralExpression::long(*n)).to_proto(),
+                Expression::Literal(LiteralExpression::int(*n as 
i32)).to_proto(),
             )),
         ),
     };
diff --git a/crates/spark-connect/src/functions.rs 
b/crates/spark-connect/src/functions.rs
index 6da75b7..3020388 100644
--- a/crates/spark-connect/src/functions.rs
+++ b/crates/spark-connect/src/functions.rs
@@ -1045,8 +1045,9 @@ pub fn kurtosis(col1: Column) -> Column {
 }
 
 /// Mirrors `pyspark.sql.functions.lag`.
-pub fn lag(col1: Column) -> Column {
-    func("lag", vec![col1.expression().clone(), lit_int(1)])
+pub fn lag<C: Into<Column>>(cols: impl IntoIterator<Item = C>) -> Column {
+    let cols: Vec<Column> = cols.into_iter().map(Into::into).collect();
+    func("lag", cols.iter().map(|c| c.expression().clone()).collect())
 }
 
 /// Mirrors `pyspark.sql.functions.last`.
@@ -1070,8 +1071,12 @@ pub fn lcase(col1: Column) -> Column {
 }
 
 /// Mirrors `pyspark.sql.functions.lead`.
-pub fn lead(col1: Column) -> Column {
-    func("lead", vec![col1.expression().clone(), lit_int(1)])
+pub fn lead<C: Into<Column>>(cols: impl IntoIterator<Item = C>) -> Column {
+    let cols: Vec<Column> = cols.into_iter().map(Into::into).collect();
+    func(
+        "lead",
+        cols.iter().map(|c| c.expression().clone()).collect(),
+    )
 }
 
 /// Mirrors `pyspark.sql.functions.length`.
@@ -2208,10 +2213,11 @@ pub fn instr(col1: Column, col2: Column) -> Column {
 }
 
 /// Mirrors `pyspark.sql.functions.json_tuple`.
-pub fn json_tuple(col1: Column, col2: Column) -> Column {
+pub fn json_tuple<C: Into<Column>>(cols: impl IntoIterator<Item = C>) -> 
Column {
+    let cols: Vec<Column> = cols.into_iter().map(Into::into).collect();
     func(
         "json_tuple",
-        vec![col1.expression().clone(), col2.expression().clone()],
+        cols.iter().map(|c| c.expression().clone()).collect(),
     )
 }
 
@@ -2785,12 +2791,11 @@ pub fn uniform(col1: Column, col2: Column) -> Column {
     )
 }
 
-/// Mirrors `pyspark.sql.functions.when`.
+/// Mirrors `pyspark.sql.functions.when`: starts a CASE WHEN (so chaining
+/// `.when(...).otherwise(...)` appends branches) rather than an opaque
+/// `when(...)` UnresolvedFunction (which would drop earlier branches on 
chaining).
 pub fn when(col1: Column, col2: Column) -> Column {
-    func(
-        "when",
-        vec![col1.expression().clone(), col2.expression().clone()],
-    )
+    crate::column::when(col1, col2)
 }
 
 /// Mirrors `pyspark.sql.functions.xpath`.
@@ -4989,7 +4994,7 @@ mod tests {
             ilike(c1.clone(), c2.clone()),
             instr(c1.clone(), c2.clone()),
             jaro_winkler_similarity(c1.clone(), c2.clone()),
-            json_tuple(c1.clone(), c2.clone()),
+            json_tuple([c1.clone(), c2.clone()]),
             kll_sketch_get_quantile_bigint(c1.clone(), c2.clone()),
             kll_sketch_get_quantile_double(c1.clone(), c2.clone()),
             kll_sketch_get_quantile_float(c1.clone(), c2.clone()),
diff --git a/crates/spark-connect/src/plan.rs b/crates/spark-connect/src/plan.rs
index 0c280c7..c148f6f 100644
--- a/crates/spark-connect/src/plan.rs
+++ b/crates/spark-connect/src/plan.rs
@@ -33,8 +33,12 @@ pub enum LogicalPlan {
         step: i64,
         num_partitions: Option<i32>,
     },
-    /// A SQL query: `sql("SELECT ...")`.
-    Sql { query: String },
+    /// A SQL query: `sql("SELECT ...")`, with optional positional/named 
parameter bindings.
+    Sql {
+        query: String,
+        pos_args: Vec<Expression>,
+        named_args: HashMap<String, Expression>,
+    },
     /// A projection (select): `df.select(cols...)`.
     Project {
         input: Box<LogicalPlan>,
@@ -418,9 +422,19 @@ impl LogicalPlan {
                 relation.rel_type = 
Some(proto::relation::RelType::Range(range));
             }
 
-            LogicalPlan::Sql { query } => {
+            LogicalPlan::Sql {
+                query,
+                pos_args,
+                named_args,
+            } => {
                 let mut sql = proto::Sql::default();
                 sql.query = query.clone();
+                for e in pos_args {
+                    sql.pos_arguments.push(e.to_proto());
+                }
+                for (k, v) in named_args {
+                    sql.named_arguments.insert(k.clone(), v.to_proto());
+                }
                 relation.rel_type = Some(proto::relation::RelType::Sql(sql));
             }
 
@@ -1313,6 +1327,8 @@ pub fn range_with_partitions(start: i64, end: i64, step: 
i64, num_partitions: i3
 pub fn sql(query: impl Into<String>) -> LogicalPlan {
     LogicalPlan::Sql {
         query: query.into(),
+        pos_args: Vec::new(),
+        named_args: HashMap::new(),
     }
 }
 
diff --git a/crates/spark-connect/src/session.rs 
b/crates/spark-connect/src/session.rs
index 097cace..eff2adf 100644
--- a/crates/spark-connect/src/session.rs
+++ b/crates/spark-connect/src/session.rs
@@ -255,8 +255,21 @@ impl SparkSession {
     ///
     /// Mirrors `pyspark.sql.SparkSession.sql(sqlQuery)`.
     pub fn sql(&self, query: &str) -> Result<DataFrame> {
+        self.sql_with_args(query, Vec::new(), std::collections::HashMap::new())
+    }
+
+    /// SQL with parameters: positional (`pos_args`) and/or named 
(`named_args`) expression
+    /// bindings, mirroring `SparkSession.sql(query, args=...)`.
+    pub fn sql_with_args(
+        &self,
+        query: &str,
+        pos_args: Vec<crate::expression::Expression>,
+        named_args: std::collections::HashMap<String, 
crate::expression::Expression>,
+    ) -> Result<DataFrame> {
         let plan = LogicalPlan::Sql {
             query: query.to_string(),
+            pos_args,
+            named_args,
         };
         Ok(DataFrame::new(self.clone(), plan))
     }
@@ -265,13 +278,15 @@ impl SparkSession {
     ///
     /// Mirrors `pyspark.sql.SparkSession.createDataFrame(rows, schema)`.
     pub fn create_dataframe(&self, rows: Vec<Row>, schema: DataType) -> 
Result<DataFrame> {
-        // Build Arrow RecordBatch from rows and schema
-        let arrow_data = rows_to_arrow_ipc(&rows, &schema)?;
-
-        let plan = LogicalPlan::LocalRelation {
-            schema,
-            data: Some(arrow_data),
+        // Empty data -> send the schema only (no Arrow `data`); the server 
builds an empty
+        // relation from the schema. A zero-row Arrow payload would be empty 
bytes, which the
+        // server cannot parse ("Unexpected end of input. Missing schema.").
+        let data = if rows.is_empty() {
+            None
+        } else {
+            Some(rows_to_arrow_ipc(&rows, &schema)?)
         };
+        let plan = LogicalPlan::LocalRelation { schema, data };
         Ok(DataFrame::new(self.clone(), plan))
     }
 
@@ -833,15 +848,51 @@ fn values_to_arrow(values: &[Value], dt: &DataType) -> 
Result<Arc<dyn arrow::arr
         }};
     }
 
+    // Numeric encoders coerce across integer/float widths, so a value the 
schema-unaware
+    // Python->Value conversion produced as a wider/narrower numeric (e.g. a 
nested-struct
+    // int arriving as Long) still matches its declared field type.
+    macro_rules! int_prim {
+        ($arr:ty, $t:ty) => {{
+            let vals: Result<Vec<_>> = values
+                .iter()
+                .map(|v| match v {
+                    Value::Null => Ok(None),
+                    Value::Byte(x) => Ok(Some(*x as $t)),
+                    Value::Short(x) => Ok(Some(*x as $t)),
+                    Value::Integer(x) => Ok(Some(*x as $t)),
+                    Value::Long(x) => Ok(Some(*x as $t)),
+                    _ => Err(SparkError::connect_msg("Type mismatch in row 
data")),
+                })
+                .collect();
+            Ok(Arc::new(<$arr>::from(vals?)) as Arc<dyn Array>)
+        }};
+    }
+    macro_rules! float_prim {
+        ($arr:ty, $t:ty) => {{
+            let vals: Result<Vec<_>> = values
+                .iter()
+                .map(|v| match v {
+                    Value::Null => Ok(None),
+                    Value::Float(x) => Ok(Some(*x as $t)),
+                    Value::Double(x) => Ok(Some(*x as $t)),
+                    Value::Integer(x) => Ok(Some(*x as $t)),
+                    Value::Long(x) => Ok(Some(*x as $t)),
+                    _ => Err(SparkError::connect_msg("Type mismatch in row 
data")),
+                })
+                .collect();
+            Ok(Arc::new(<$arr>::from(vals?)) as Arc<dyn Array>)
+        }};
+    }
+
     match dt {
         DataType::Null => Ok(Arc::new(NullArray::new(values.len()))),
         DataType::Boolean => prim!(BooleanArray, Value::Bool),
-        DataType::Byte => prim!(Int8Array, Value::Byte),
-        DataType::Short => prim!(Int16Array, Value::Short),
-        DataType::Integer => prim!(Int32Array, Value::Integer),
-        DataType::Long => prim!(Int64Array, Value::Long),
-        DataType::Float => prim!(Float32Array, Value::Float),
-        DataType::Double => prim!(Float64Array, Value::Double),
+        DataType::Byte => int_prim!(Int8Array, i8),
+        DataType::Short => int_prim!(Int16Array, i16),
+        DataType::Integer => int_prim!(Int32Array, i32),
+        DataType::Long => int_prim!(Int64Array, i64),
+        DataType::Float => float_prim!(Float32Array, f32),
+        DataType::Double => float_prim!(Float64Array, f64),
         DataType::String { .. } | DataType::Char { .. } | DataType::Varchar { 
.. } => {
             let vals: Result<Vec<Option<&str>>> = values
                 .iter()
@@ -959,6 +1010,14 @@ fn values_to_arrow(values: &[Value], dt: &DataType) -> 
Result<Arc<dyn arrow::arr
                             cols[i].push(val);
                         }
                     }
+                    // A Python dict supplied for a struct field (e.g. 
array<struct> data)
+                    // arrives as a Map; match its entries to the struct 
fields by name.
+                    Value::Map(m) => {
+                        valid.append(true);
+                        for (i, f) in fields.iter().enumerate() {
+                            
cols[i].push(m.get(&f.name).cloned().unwrap_or(Value::Null));
+                        }
+                    }
                     _ => return Err(SparkError::connect_msg("Type mismatch: 
expected struct")),
                 }
             }
diff --git a/crates/spark-connect/src/types.rs 
b/crates/spark-connect/src/types.rs
index 1013be6..aa8a42d 100644
--- a/crates/spark-connect/src/types.rs
+++ b/crates/spark-connect/src/types.rs
@@ -1362,16 +1362,38 @@ fn parse_geography(s: &str) -> Result<DataType> {
 fn parse_datatype_string(input: &str) -> Result<DataType> {
     let trimmed = input.trim();
 
-    // Check if it's a top-level schema (multiple fields like "a INT, b 
STRING")
-    // Heuristic: contains comma at top level (not inside angle 
brackets/parens)
-    // and doesn't start with "struct<"
-    if !trimmed.to_lowercase().starts_with("struct<") && 
contains_top_level_comma(trimmed) {
-        return parse_top_level_schema(trimmed);
+    // A top-level schema is "a INT, b STRING" (multiple fields) OR a single 
"a INT"
+    // (one field, no comma) -- both parse to a StructType, matching PySpark's
+    // _parse_datatype_string. Detect either a top-level comma or a top-level 
space
+    // separating a field name from its type; fall back to single-type parsing 
if the
+    // schema interpretation fails (e.g. a bare type that itself contains 
spaces such as
+    // "interval day to second").
+    if !trimmed.to_lowercase().starts_with("struct<")
+        && (contains_top_level_comma(trimmed) || 
contains_top_level_whitespace(trimmed))
+    {
+        if let Ok(dt) = parse_top_level_schema(trimmed) {
+            return Ok(dt);
+        }
     }
 
     parse_single_type(trimmed)
 }
 
+/// Whether `s` has a top-level whitespace char (outside `<>`/`()`), i.e. it 
looks like a
+/// "name type" field rather than a bare type such as `int` or `array<int>`.
+fn contains_top_level_whitespace(s: &str) -> bool {
+    let mut depth = 0;
+    for c in s.chars() {
+        match c {
+            '<' | '(' => depth += 1,
+            '>' | ')' => depth -= 1,
+            _ if depth == 0 && c.is_whitespace() => return true,
+            _ => {}
+        }
+    }
+    false
+}
+
 /// Check if a string contains a comma at the top level (not inside 
brackets/parens)
 fn contains_top_level_comma(s: &str) -> bool {
     let mut depth = 0;
diff --git a/crates/spark-connect/tests/functions_golden.rs 
b/crates/spark-connect/tests/functions_golden.rs
index e0c1348..885653c 100644
--- a/crates/spark-connect/tests/functions_golden.rs
+++ b/crates/spark-connect/tests/functions_golden.rs
@@ -308,14 +308,14 @@ fn all_golden_function_cases_pass() {
         ("java_method", java_method(Vec::<Column>::new())),
         ("json_array_length", json_array_length(a())),
         ("json_object_keys", json_object_keys(a())),
-        ("json_tuple", json_tuple(a(), b())),
+        ("json_tuple", json_tuple([a(), b()])),
         ("kurtosis", kurtosis(a())),
-        ("lag", lag(a())),
+        ("lag", lag([a()])),
         ("last", last(a())),
         ("last_day", last_day(a())),
         ("last_value", last_value(a())),
         ("lcase", lcase(a())),
-        ("lead", lead(a())),
+        ("lead", lead([a()])),
         ("least", least(a(), b())),
         ("left", left(a(), b())),
         ("length", length(a())),
diff --git a/crates/spark-connect/tests/window_golden.rs 
b/crates/spark-connect/tests/window_golden.rs
index 781048b..ce064a2 100644
--- a/crates/spark-connect/tests/window_golden.rs
+++ b/crates/spark-connect/tests/window_golden.rs
@@ -230,7 +230,7 @@ fn build(name: &str) -> Option<Expression> {
             .expression()
             .clone(),
 
-        "window_lag" => functions::lag(col("x"))
+        "window_lag" => functions::lag([col("x")])
             .over(
                 WindowSpec::new()
                     .partition_by(vec![col("a").expression().clone()])
@@ -241,7 +241,7 @@ fn build(name: &str) -> Option<Expression> {
             .expression()
             .clone(),
 
-        "window_lead" => functions::lead(col("x"))
+        "window_lead" => functions::lead([col("x")])
             .over(
                 WindowSpec::new()
                     .partition_by(vec![col("a").expression().clone()])
diff --git a/python/pyspark/sql/functions.py b/python/pyspark/sql/functions.py
index 8c4cdf2..ec00181 100644
--- a/python/pyspark/sql/functions.py
+++ b/python/pyspark/sql/functions.py
@@ -420,6 +420,27 @@ for name in dir(_gen_module):
     if not name.startswith('_'):
         globals()[name] = getattr(_gen_module, name)
 
+
+# Overrides for functions whose Python signature carries extra literal args 
that the
+# generated single-arg wrapper cannot express (offset/default, variadic field 
names).
+# Defined AFTER the generated import so they take precedence. 
Field/offset/default are
+# literals; only the first arg is a column (PySpark ColumnOrName).
+def lag(col, offset=1, default=None):
+    """lag(col, offset=1, default=None): value offset rows before the current 
row."""
+    return _wrap(_call_function("lag", _to_col(col), _pyfunc_lit(offset), 
_pyfunc_lit(default)))
+
+
+def lead(col, offset=1, default=None):
+    """lead(col, offset=1, default=None): value offset rows after the current 
row."""
+    return _wrap(_call_function("lead", _to_col(col), _pyfunc_lit(offset), 
_pyfunc_lit(default)))
+
+
+def json_tuple(col, *fields):
+    """json_tuple(col, *fields): extract the given JSON fields (literal names) 
as columns."""
+    if not fields:
+        raise ValueError("json_tuple() takes at least one field name")
+    return _wrap(_call_function("json_tuple", _to_col(col), *[_pyfunc_lit(f) 
for f in fields]))
+
 # ---------------------------------------------------------------------------
 # Higher-order functions.
 #
diff --git a/python/tests/test_dropin_offline.py 
b/python/tests/test_dropin_offline.py
index b7402a4..14e236c 100644
--- a/python/tests/test_dropin_offline.py
+++ b/python/tests/test_dropin_offline.py
@@ -337,6 +337,10 @@ def test_datatype_object_model():
     assert T.IntegerType().toInternal(5) == 5
     # fromDDL parses a DDL string client-side.
     assert T.DataType.fromDDL("a int, b string").simpleString() == 
"struct<a:int,b:string>"
+    # A single-field DDL schema (no comma) must also parse to a one-field 
struct, not be
+    # misread as a bare type (SPARK-59113 follow-up).
+    assert T.DataType.fromDDL("i int").simpleString() == "struct<i:int>"
+    assert T.StructType.fromDDL("id long").simpleString() == 
"struct<id:bigint>"
 
 
 def test_structfield_collation_methods():


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

Reply via email to