viirya commented on code in PR #149:
URL: 
https://github.com/apache/spark-connect-rust/pull/149#discussion_r4476256657


##########
crates/pyspark-rs/src/session.rs:
##########
@@ -449,89 +455,154 @@ impl PySparkSession {
                     // column (matching pyspark createDataFrame(data, 
AtomicType())).
                     match crate::types::py_to_data_type(s)? {
                         DataType::Struct { fields } => Spec::Struct(fields),
-                        atomic => 
Spec::Struct(vec![spark_connect::types::StructField {
-                            name: "value".to_string(),
-                            data_type: atomic,
-                            nullable: true,
-                            metadata: std::collections::BTreeMap::new(),
-                        }]),
+                        atomic => {
+                            atomic_schema = true;
+                            
Spec::Struct(vec![spark_connect::types::StructField {
+                                name: "value".to_string(),
+                                data_type: atomic,
+                                nullable: true,
+                                metadata: std::collections::BTreeMap::new(),
+                            }])
+                        }
                     }
                 }
             }
         };
 
-        let spec_names: Option<Vec<String>> = match &spec {
-            Spec::Names(n) => Some(n.clone()),
-            Spec::Struct(f) => Some(f.iter().map(|x| 
x.name.clone()).collect()),
-            Spec::None => None,
-        };
+        // As pyspark, empty input needs a schema to type its relation, and 
gets no toDF.
+        let empty = data.is_empty();
+        if empty && !matches!(spec, Spec::Struct(_)) {
+            return Err(crate::column::raise_pyspark(
+                py,
+                "PySparkValueError",
+                "CANNOT_INFER_EMPTY_SCHEMA",
+                &[],
+            ));
+        }
+        if let (false, Some(columns)) = (empty, &pandas_columns) {
+            if atomic_schema {
+                let data_type = match schema {
+                    Some(s) => s.repr()?.extract::<String>()?,

Review Comment:
   With a DDL string that parses to an atomic type, e.g. `createDataFrame(pdf, 
"int")`, `s.repr()` is the repr of the Python string, so `data_type` comes out 
as `'int'`. PySpark parses the DDL first and reports `IntegerType()`. Could 
this use `str()` of the parsed type, via `data_type_to_py`? PySpark uses 
`str(schema)`. A test case with a DDL atomic schema would cover it.



##########
crates/pyspark-rs/src/session.rs:
##########
@@ -449,89 +455,154 @@ impl PySparkSession {
                     // column (matching pyspark createDataFrame(data, 
AtomicType())).
                     match crate::types::py_to_data_type(s)? {
                         DataType::Struct { fields } => Spec::Struct(fields),
-                        atomic => 
Spec::Struct(vec![spark_connect::types::StructField {
-                            name: "value".to_string(),
-                            data_type: atomic,
-                            nullable: true,
-                            metadata: std::collections::BTreeMap::new(),
-                        }]),
+                        atomic => {
+                            atomic_schema = true;
+                            
Spec::Struct(vec![spark_connect::types::StructField {
+                                name: "value".to_string(),
+                                data_type: atomic,
+                                nullable: true,
+                                metadata: std::collections::BTreeMap::new(),
+                            }])
+                        }
                     }
                 }
             }
         };
 
-        let spec_names: Option<Vec<String>> = match &spec {
-            Spec::Names(n) => Some(n.clone()),
-            Spec::Struct(f) => Some(f.iter().map(|x| 
x.name.clone()).collect()),
-            Spec::None => None,
-        };
+        // As pyspark, empty input needs a schema to type its relation, and 
gets no toDF.
+        let empty = data.is_empty();
+        if empty && !matches!(spec, Spec::Struct(_)) {
+            return Err(crate::column::raise_pyspark(
+                py,
+                "PySparkValueError",
+                "CANNOT_INFER_EMPTY_SCHEMA",
+                &[],
+            ));
+        }
+        if let (false, Some(columns)) = (empty, &pandas_columns) {

Review Comment:
   nit: `if !empty { if let Some(columns) = &pandas_columns { ... } }` reads 
more directly than matching on `(false, Some(..))`.



##########
crates/pyspark-rs/src/session.rs:
##########
@@ -449,89 +455,154 @@ impl PySparkSession {
                     // column (matching pyspark createDataFrame(data, 
AtomicType())).
                     match crate::types::py_to_data_type(s)? {
                         DataType::Struct { fields } => Spec::Struct(fields),
-                        atomic => 
Spec::Struct(vec![spark_connect::types::StructField {
-                            name: "value".to_string(),
-                            data_type: atomic,
-                            nullable: true,
-                            metadata: std::collections::BTreeMap::new(),
-                        }]),
+                        atomic => {
+                            atomic_schema = true;
+                            
Spec::Struct(vec![spark_connect::types::StructField {
+                                name: "value".to_string(),
+                                data_type: atomic,
+                                nullable: true,
+                                metadata: std::collections::BTreeMap::new(),
+                            }])
+                        }
                     }
                 }
             }
         };
 
-        let spec_names: Option<Vec<String>> = match &spec {
-            Spec::Names(n) => Some(n.clone()),
-            Spec::Struct(f) => Some(f.iter().map(|x| 
x.name.clone()).collect()),
-            Spec::None => None,
-        };
+        // As pyspark, empty input needs a schema to type its relation, and 
gets no toDF.
+        let empty = data.is_empty();
+        if empty && !matches!(spec, Spec::Struct(_)) {
+            return Err(crate::column::raise_pyspark(
+                py,
+                "PySparkValueError",
+                "CANNOT_INFER_EMPTY_SCHEMA",
+                &[],
+            ));
+        }
+        if let (false, Some(columns)) = (empty, &pandas_columns) {
+            if atomic_schema {
+                let data_type = match schema {
+                    Some(s) => s.repr()?.extract::<String>()?,
+                    None => String::new(),
+                };
+                return Err(crate::column::raise_pyspark(
+                    py,
+                    "PySparkTypeError",
+                    "UNSUPPORTED_DATA_TYPE_FOR_ARROW",
+                    &[("data_type", &data_type)],
+                ));
+            }
+            if matches!(spec, Spec::None) && columns.is_empty() {
+                return Err(crate::column::raise_pyspark(
+                    py,
+                    "PySparkValueError",
+                    "CANNOT_INFER_EMPTY_SCHEMA",
+                    &[],
+                ));
+            }
+        }
 
         // Parse each item into values; a list/tuple carries no names, a Row 
carries its own.
-        let mut rows: Vec<Row> = vec![];
+        let mut items: Vec<(Vec<Value>, Option<Vec<String>>)> = 
Vec::with_capacity(data.len());
         for item in data.iter() {
-            let (values, row_names): (Vec<Value>, Option<Vec<String>>) =
-                if let Ok(pyrow) = item.extract::<PyRef<PyRow>>() {
-                    (
-                        pyrow.row.values().to_vec(),
-                        Some(pyrow.row.fields().to_vec()),
-                    )
-                } else if let Ok(lst) = item.cast::<PyList>() {
-                    (
-                        lst.iter()
-                            .map(|v| py_to_value(&v))
-                            .collect::<PyResult<_>>()?,
-                        None,
-                    )
-                } else if let Ok(tuple) = item.cast::<pyo3::types::PyTuple>() {
-                    (
-                        tuple
-                            .iter()
-                            .map(|v| py_to_value(&v))
-                            .collect::<PyResult<_>>()?,
-                        None,
-                    )
-                } else if let Ok(dict) = item.cast::<PyDict>() {
-                    // A dict row: field names are its keys (sorted, mirroring 
pyspark's
-                    // `_infer_schema` for dicts), values in that key order.
-                    let mut pairs: Vec<(String, Bound<'_, PyAny>)> = 
Vec::with_capacity(dict.len());
-                    for (k, v) in dict.iter() {
-                        pairs.push((k.str()?.to_string(), v));
-                    }
-                    pairs.sort_by(|a, b| a.0.cmp(&b.0));
-                    let names: Vec<String> = pairs.iter().map(|(k, _)| 
k.clone()).collect();
-                    let values: Vec<Value> = pairs
+            let parsed = if let Ok(pyrow) = item.extract::<PyRef<PyRow>>() {
+                (
+                    pyrow.row.values().to_vec(),
+                    Some(pyrow.row.fields().to_vec()),
+                )
+            } else if let Ok(lst) = item.cast::<PyList>() {
+                (
+                    lst.iter()
+                        .map(|v| py_to_value(&v))
+                        .collect::<PyResult<_>>()?,
+                    None,
+                )
+            } else if let Ok(tuple) = item.cast::<pyo3::types::PyTuple>() {
+                (
+                    tuple
                         .iter()
-                        .map(|(_, v)| py_to_value(v))
-                        .collect::<PyResult<_>>()?;
-                    (values, Some(names))
-                } else {
-                    // A scalar row (int/str/float/...): a single-field row, 
matching
-                    // `createDataFrame([1, 2, 3], IntegerType())`. The field 
name comes from
-                    // the schema when given, else defaults below.
-                    (vec![py_to_value(&item)?], None)
-                };
-            let names = spec_names
-                .clone()
-                .or(row_names)
-                .or_else(|| pandas_columns.clone())
-                .unwrap_or_else(|| (0..values.len()).map(|i| format!("col{}", 
i)).collect());
+                        .map(|v| py_to_value(&v))
+                        .collect::<PyResult<_>>()?,
+                    None,
+                )
+            } else if let Ok(dict) = item.cast::<PyDict>() {
+                // A dict row: field names are its keys (sorted, mirroring 
pyspark's
+                // `_infer_schema` for dicts), values in that key order.
+                let mut pairs: Vec<(String, Bound<'_, PyAny>)> = 
Vec::with_capacity(dict.len());
+                for (k, v) in dict.iter() {
+                    pairs.push((k.str()?.to_string(), v));
+                }
+                pairs.sort_by(|a, b| a.0.cmp(&b.0));
+                let names: Vec<String> = pairs.iter().map(|(k, _)| 
k.clone()).collect();
+                let values: Vec<Value> = pairs
+                    .iter()
+                    .map(|(_, v)| py_to_value(v))
+                    .collect::<PyResult<_>>()?;
+                (values, Some(names))
+            } else {
+                // A scalar row (int/str/float/...): a single-field row, 
matching
+                // `createDataFrame([1, 2, 3], IntegerType())`. The field name 
comes from
+                // the schema when given, else defaults below.
+                (vec![py_to_value(&item)?], None)
+            };
+            items.push(parsed);
+        }
+
+        // Plain rows (no names of their own) are as wide as the widest of 
them: pyspark's
+        // schema inference names them `_1`, `_2`, ..., and extends a shorter 
list of names
+        // the same way.
+        let plain_width = items
+            .iter()
+            .filter(|(_, names)| names.is_none())
+            .map(|(values, _)| values.len())
+            .max()
+            .unwrap_or(0);
+        let extend = |names: &[String], width: usize| -> Vec<String> {
+            let mut names = names.to_vec();
+            names.extend((names.len()..width).map(|i| format!("_{}", i + 1)));
+            names
+        };
+        // The names pyspark applies with toDF: a list of names, and the 
columns of a pandas
+        // DataFrame or the field names of its schema.
+        let to_df_names: Option<Vec<String>> = match (&spec, &pandas_columns) {
+            _ if empty => None,
+            (Spec::Names(names), Some(columns)) => Some(extend(names, 
columns.len())),
+            (Spec::Names(names), None) => Some(extend(names, plain_width)),
+            (Spec::None, Some(columns)) => Some(columns.clone()),
+            (Spec::Struct(fields), Some(_)) => {
+                Some(fields.iter().map(|f| f.name.clone()).collect())
+            }
+            (_, None) => None,
+        };
+        let default_names = extend(&[], plain_width);
+
+        let mut rows: Vec<Row> = Vec::with_capacity(items.len());
+        for (values, row_names) in items {
+            let names = match (&spec, &to_df_names) {
+                (Spec::Struct(fields), _) => fields.iter().map(|f| 
f.name.clone()).collect(),
+                (_, Some(names)) => names.clone(),
+                _ => row_names.unwrap_or_else(|| default_names.clone()),
+            };
             if names.len() != values.len() {
-                return Err(row_width_mismatch(rows.len(), values.len(), 
names.len())).to_pyerr();
+                // As pyspark, which checks every row against the width of the 
schema.

Review Comment:
   This isn't quite what PySpark does for dict rows. PySpark's `to_row` looks 
dicts up by name (`item.get(col)`): missing keys become None and extra keys are 
ignored. Here the sorted values are matched by position. So a dict with a 
missing key raises `AXIS_LENGTH_MISMATCH` instead of filling None, and 
`createDataFrame([{"b": 1, "a": "x"}], "b long, a string")` puts `"x"` into 
`b`. That predates this PR, but the comment and the description ("for every 
kind of schema") now say this matches PySpark. Could we narrow both to plain 
rows and Rows, and track dict-by-name matching separately (or fix it here, 
since this loop is already being changed)?



##########
python/tests/test_dropin_offline.py:
##########
@@ -2903,13 +2903,87 @@ def 
test_create_dataframe_width_mismatch_is_a_normal_exception():
     try:
         spark.createDataFrame([(1, 2), (3,)], schema=schema)
     except PySparkValueError as error:
+        # The same error as pyspark, for every kind of schema.
         assert type(error) is PySparkValueError, type(error)
-        assert str(error) == "Row 1 has 1 values, but the schema has 2 
fields", error
+        assert error.getCondition() == "AXIS_LENGTH_MISMATCH", error
+        assert error.getMessageParameters() == {
+            "expected_length": "2", "actual_length": "1"}, error
     else:
         raise AssertionError("expected a width validation error")
 """], check=True)
 
 
+def _local_create_dataframe(*args):
+    # The local relation and its toDF are built without a server.
+    return _offline_session().createDataFrame(*args)
+
+
[email protected]("data,schema,expected", [
+    # Column names are applied with toDF, as in pyspark, so the result is not 
local.
+    ([(1, 2)], ["a", "b"], False),
+    ([(1, 2)], ("a", "b"), False),
+    ([(1, 2)], ["a"], False),
+    ([(1, 2)], [], False),
+    ([T.Row(x=1, y=2)], ["a", "b"], False),
+    ([{"x": 1, "y": 2}], ["a", "b"], False),
+    # Without names to apply, the local relation itself is local.
+    ([(1, 2)], None, True),
+    ([T.Row(x=1, y=2)], None, True),
+    ([(1, 2)], "a long, b long", True),
+    ([1, 2], T.LongType(), True),
+    ([], "a long", True),
+])
+def test_create_dataframe_rows_is_local_as_pyspark(data, schema, expected):
+    assert _local_create_dataframe(data, schema).isLocal() is expected
+
+
[email protected]("schema", [None, ["c", "d"], ["c"], "a long, b 
string",
+                                    T.StructType([T.StructField("a", 
T.LongType()),
+                                                  T.StructField("b", 
T.StringType())])])
+def test_create_dataframe_pandas_is_not_local_as_pyspark(schema):
+    pd = pytest.importorskip("pandas")
+    # pyspark applies the columns, or the field names of the schema, with toDF.
+    df = _local_create_dataframe(pd.DataFrame({"a": [1, 2], "b": ["x", "y"]}), 
schema)
+    assert not df.isLocal()
+    # Empty input returns the relation of its schema, without toDF.

Review Comment:
   This assertion doesn't depend on `schema`, so it runs once per 
parametrization. Could it move to 
`test_create_dataframe_pandas_errors_as_pyspark` or a separate test?



##########
crates/pyspark-rs/src/session.rs:
##########
@@ -554,7 +625,14 @@ impl PySparkSession {
         let df = py
             .detach(|| self.session.create_dataframe(rows, schema_dtype))
             .to_pyerr()?;
-        Ok(PyDataFrame::new(df))
+        // As pyspark, column names are applied with toDF, which the server 
resolves into a

Review Comment:
   nit: the local relation already carries these names, so this `toDF` only 
changes the plan shape and `isLocal()`. Saying that explicitly would stop a 
later cleanup from removing it as redundant.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


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

Reply via email to