cloud-fan commented on code in PR #58549:
URL: https://github.com/apache/spark/pull/58549#discussion_r4043362968


##########
python/pyspark/sql/connect/session.py:
##########
@@ -501,6 +504,13 @@ def createDataFrame(
                 _num_cols = len(schema.fields)
             else:
                 _num_cols = 1
+            if _has_physical_type(schema, (CharType, VarcharType)):

Review Comment:
   **Non-blocking (P2):** This guard runs before `_inferSchemaFromList`, so 
`schema=None` can still infer a UDT whose storage contains CHAR/VARCHAR after 
the check. The widened Arrow mapper then accepts those leaves as UTF8 and sends 
an unsupported LocalRelation instead of producing the intended validation 
error. Could we apply the same physical-type rejection to the finalized 
inferred schema before `LocalDataToArrowConversion`?



##########
sql/core/src/test/scala/org/apache/spark/sql/execution/python/ArrowColumnarPythonUDFSuite.scala:
##########
@@ -103,6 +111,181 @@ class ArrowColumnarPythonUDFSuite extends 
SharedSparkSession {
     }
   }
 
+  test("Arrow-backed source: CHAR/VARCHAR output checks") {

Review Comment:
   **Non-blocking (P2):** The Arrow-backed cases here cover top-level results 
with one policy at a time, so regressions in nested `physicalOutputSchema` 
recursion or `checkedOutput.zip(udfs)` remain invisible. Please add a nested 
constrained result and checked/unchecked sibling UDFs in the same Arrow-backed 
plan, asserting padding/overflow and the policy at each output ordinal.



##########
python/pyspark/sql/connect/udtf.py:
##########
@@ -166,10 +171,33 @@ def __init__(
         self._name = name or func.__name__
         self.evalType = evalType
         self.deterministic = deterministic
+        self._validated_return_type_session_ids: Set[str] = set()

Review Comment:
   **Nit (P3):** This set retains every validated session UUID for the lifetime 
of the UDTF object, including sessions that have been closed and can never 
reuse the cached fact. Reusing one UDTF across many sessions therefore grows 
state monotonically. Could this be a bounded current-session memo (or otherwise 
evict expired owners) while retaining same-session reuse?



##########
python/pyspark/sql/tests/test_udf.py:
##########
@@ -55,10 +63,198 @@
     test_not_compiled_message,
 )
 from pyspark.testing.utils import assertDataFrameEqual, eventually, timeout
-from pyspark.util import is_remote_only
+from pyspark.util import PythonEvalType, is_remote_only
 
 
 class BaseUDFTestsMixin:
+    def test_char_varchar_results(self):
+        schema = StructType(
+            [
+                StructField("c", CharType(4)),
+                StructField("v", VarcharType(3)),
+                StructField("nested", ArrayType(CharType(2))),
+                StructField("m", MapType(CharType(2), VarcharType(3))),
+            ]
+        )
+
+        with self.sql_conf(
+            {
+                "spark.sql.legacy.charVarcharAsString": "false",
+                "spark.sql.preserveCharVarcharTypeInfo": "false",
+                "spark.sql.charVarchar.standardSemantics.enabled": "false",
+            }
+        ):
+            default_result = self.spark.range(1).select(
+                udf(lambda _: "a", CharType(3), 
useArrow=False)("id").alias("c")
+            )
+            self.assertEqual(default_result.schema["c"].dataType, StringType())
+            self.assertEqual(default_result.first().c, "a  ")
+
+        with self.sql_conf({"spark.sql.charVarchar.standardSemantics.enabled": 
"true"}):
+            result = self.spark.range(1).select(
+                udf(
+                    lambda _: ("ab", "xyz", ["z"], {"k": "xy"}),
+                    schema,
+                    useArrow=False,
+                )("id").alias("s")
+            )
+            self.assertEqual(
+                result.first().s,
+                Row(c="ab  ", v="xyz", nested=["z "], m={"k ": "xy"}),
+            )
+
+            invalid = self.spark.range(1).select(
+                udf(lambda _: "abcd", VarcharType(3), useArrow=False)("id")
+            )
+            with self.assertRaisesRegex(Exception, "EXCEED_LIMIT_LENGTH"):
+                invalid.collect()
+
+    def test_char_varchar_legacy_as_string(self):
+        with self.sql_conf(
+            {
+                "spark.sql.legacy.charVarcharAsString": "true",
+                "spark.sql.preserveCharVarcharTypeInfo": "false",
+                "spark.sql.charVarchar.standardSemantics.enabled": "false",
+            }
+        ):
+            result = self.spark.range(1).select(
+                udf(lambda _: "a", CharType(3), 
useArrow=False)("id").alias("c"),
+                udf(lambda _: "abcd", VarcharType(3), 
useArrow=False)("id").alias("v"),
+            )
+            self.assertEqual(result.first(), Row(c="a", v="abcd"))
+
+    def test_char_varchar_intermediate_udf_results(self):
+        inner_char = udf(lambda _: "a", CharType(3), useArrow=False)
+        inner_varchar = udf(lambda _: "abcd", VarcharType(3), useArrow=False)
+        outer = udf(lambda value: value, StringType(), useArrow=False)
+
+        with self.sql_conf({"spark.sql.charVarchar.standardSemantics.enabled": 
"true"}):
+            padded = 
self.spark.range(1).select(outer(inner_char("id")).alias("result"))
+            self.assertEqual(padded.first().result, "a  ")
+
+            invalid = self.spark.range(1).select(outer(inner_varchar("id")))
+            with self.assertRaisesRegex(Exception, "EXCEED_LIMIT_LENGTH"):
+                invalid.collect()
+
+        with self.sql_conf(
+            {
+                "spark.sql.legacy.charVarcharAsString": "true",
+                "spark.sql.preserveCharVarcharTypeInfo": "false",
+                "spark.sql.charVarchar.standardSemantics.enabled": "false",
+            }
+        ):
+            result = self.spark.range(1).select(
+                outer(inner_char("id")).alias("c"),
+                outer(inner_varchar("id")).alias("v"),
+            )
+            self.assertEqual(result.first(), Row(c="a", v="abcd"))
+
+    def test_char_varchar_view_keeps_resolved_semantics(self):
+        with self.temp_view("char_varchar_udf_view"):
+            with 
self.sql_conf({"spark.sql.charVarchar.standardSemantics.enabled": "true"}):
+                self.spark.range(1).select(
+                    udf(lambda _: "a", CharType(3), 
useArrow=False)("id").alias("c"),
+                    udf(lambda _: "abcd", VarcharType(3), 
useArrow=False)("id").alias("v"),
+                ).createOrReplaceTempView("char_varchar_udf_view")
+
+            with self.sql_conf(
+                {
+                    "spark.sql.legacy.charVarcharAsString": "true",
+                    "spark.sql.preserveCharVarcharTypeInfo": "false",
+                    "spark.sql.charVarchar.standardSemantics.enabled": "false",
+                }
+            ):
+                self.assertEqual(
+                    self.spark.sql("SELECT c FROM 
char_varchar_udf_view").collect()[0].c,
+                    "a  ",
+                )
+                with self.assertRaisesRegex(Exception, "EXCEED_LIMIT_LENGTH"):
+                    self.spark.sql("SELECT v FROM 
char_varchar_udf_view").collect()
+
+    def test_char_varchar_mixed_captured_policies_in_one_batch(self):
+        char_udf = udf(lambda _: "a", CharType(3), useArrow=False)
+        varchar_udf = udf(lambda _: "abcd", VarcharType(3), useArrow=False)
+        with self.sql_conf({"spark.sql.charVarchar.standardSemantics.enabled": 
"true"}):
+            checked_char = char_udf("id").alias("c")
+        with self.sql_conf(
+            {
+                "spark.sql.legacy.charVarcharAsString": "true",
+                "spark.sql.preserveCharVarcharTypeInfo": "false",
+                "spark.sql.charVarchar.standardSemantics.enabled": "false",
+            }
+        ):
+            unchecked_varchar = varchar_udf("id").alias("v")
+
+        self.assertEqual(
+            self.spark.range(1).select(checked_char, 
unchecked_varchar).collect()[0],
+            Row(c="a  ", v="abcd"),
+        )
+
+    def test_char_varchar_non_scalar_return_types_unsupported(self):

Review Comment:
   **Non-blocking (P2):** The rejection matrix omits several independently 
dispatched paths: row-at-a-time UDF UDT storage, `applyInPandasWithState`, 
row-mode Connect UDTF DDL, Python DataSource UDT storage, and `toArrow` UDT 
storage. Please add focused negative cases that assert the intended classed 
validation error, so removing any one physical-type guard cannot leave the 
suite green or fall through to an opaque Arrow failure.



##########
python/pyspark/sql/tests/arrow/test_arrow_udf_scalar.py:
##########
@@ -58,6 +60,50 @@
 
 @unittest.skipIf(not have_pyarrow, pyarrow_requirement_message)
 class ScalarArrowUDFTestsMixin:
+    def test_char_varchar_scalar_results(self):
+        import pyarrow as pa
+
+        @arrow_udf(CharType(3), ArrowUDFType.SCALAR)
+        def scalar_char(values):
+            return pa.array(["a"] * len(values))
+
+        @arrow_udf(CharType(3), ArrowUDFType.SCALAR_ITER)
+        def iterator_char(batches):
+            for values in batches:
+                yield pa.array(["a"] * len(values))
+
+        @arrow_udf(VarcharType(3), ArrowUDFType.SCALAR)
+        def scalar_varchar(values):
+            return pa.array(["abcd"] * len(values))
+
+        @arrow_udf(VarcharType(3), ArrowUDFType.SCALAR_ITER)
+        def iterator_varchar(batches):
+            for values in batches:
+                yield pa.array(["abcd"] * len(values))
+
+        with self.sql_conf(
+            {
+                "spark.sql.legacy.charVarcharAsString": "false",
+                "spark.sql.preserveCharVarcharTypeInfo": "false",
+                "spark.sql.charVarchar.standardSemantics.enabled": "false",
+            }
+        ):
+            for function in (scalar_char, iterator_char):
+                result = self.spark.range(1).select(function("id").alias("c"))
+                self.assertEqual(result.schema["c"].dataType, StringType())
+                self.assertEqual(result.first().c, "a  ")
+            for function in (scalar_varchar, iterator_varchar):
+                with self.assertRaisesRegex(Exception, "EXCEED_LIMIT_LENGTH"):
+                    self.spark.range(1).select(function("id")).collect()
+
+        with self.sql_conf({"spark.sql.charVarchar.standardSemantics.enabled": 
"true"}):
+            for function in (scalar_char, iterator_char):
+                rows = self.spark.range(2).select(function("id")).collect()

Review Comment:
   **Non-blocking (P2):** This standard-semantics case checks values but not 
the first-class output type. Returning `StringType` would preserve the current 
padding/overflow assertions while violating the feature's schema contract; the 
same gap exists in the pandas, higher-order, and Python-RDD creation cases. 
Please assert the retained CHAR/VARCHAR schemas, including the constrained 
array element for the higher-order result.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/util/CharVarcharUtils.scala:
##########
@@ -33,6 +33,43 @@ object CharVarcharUtils extends Logging with 
SparkCharVarcharUtils {
   // visible for testing
   private[sql] val CHAR_VARCHAR_TYPE_STRING_METADATA_KEY = 
"__CHAR_VARCHAR_TYPE_STRING"
 
+  /**
+   * Returns whether a type contains CHAR/VARCHAR, including inside UDT 
storage. This is intended
+   * for validation at boundaries that do not support CHAR/VARCHAR in UDT 
storage.
+   */
+  private[sql] def physicalTypeHasCharVarchar(dt: DataType): Boolean = dt 
match {
+    case ArrayType(elementType, _) => physicalTypeHasCharVarchar(elementType)
+    case MapType(keyType, valueType, _) =>
+      physicalTypeHasCharVarchar(keyType) || 
physicalTypeHasCharVarchar(valueType)
+    case StructType(fields) => fields.exists(f => 
physicalTypeHasCharVarchar(f.dataType))
+    case udt: UserDefinedType[_] => physicalTypeHasCharVarchar(udt.sqlType)
+    case _: CharType | _: VarcharType => true
+    case _ => false
+  }
+
+  /**
+   * Replaces CHAR/VARCHAR with their unconstrained string representation 
regardless of session

Review Comment:
   **Nit (P3):** This contract says CHAR/VARCHAR are always replaced, but the 
UDT branch returns raw `udt.sqlType` without recursively normalizing its 
storage. Since UDT-storage assignment support is explicitly out of scope here, 
please narrow the Scaladoc to state the raw-storage exception and that callers 
must reject unsupported constrained UDT storage before invoking this conversion.



##########
python/pyspark/sql/types.py:
##########
@@ -2877,8 +2877,38 @@ def _has_type(dt: DataType, dts: Union[type, Tuple[type, 
...]]) -> bool:
         return _has_type(dt.elementType, dts)
     elif isinstance(dt, MapType):
         return _has_type(dt.keyType, dts) or _has_type(dt.valueType, dts)
+    else:

Review Comment:
   **Non-blocking (P2):** Making `_has_type` stop at UDTs also changes existing 
callers whose downstream Arrow operation unwraps UDT storage. In particular, a 
UDT-backed year-month interval now bypasses the classed preflight and reaches 
PyArrow's opaque lookup failure; the pandas legacy-struct preflight has the 
same physical/logical mismatch. Could those two callers move to 
`_has_physical_type` while logical capability checks keep the new behavior?



-- 
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