cloud-fan commented on code in PR #58581:
URL: https://github.com/apache/spark/pull/58581#discussion_r4037004940
##########
sql/connect/common/src/main/scala/org/apache/spark/sql/connect/SparkSession.scala:
##########
@@ -223,7 +227,14 @@ class SparkSession private[sql] (
/** @inheritdoc */
def createDataFrame(rows: java.util.List[Row], schema: StructType):
DataFrame = {
- createDataset(RowEncoder.encoderFor(schema),
rows.iterator().asScala).toDF()
+ // RowEncoder applies CHAR/VARCHAR semantics from the client process's
local SqlApiConf, which
Review Comment:
**Blocking (P1):** This physical schema also unwraps ordinary JVM UDTs, but
the row iterator still contains their user objects. The previous logical-schema
RowEncoder selected UDTEncoder and called serialize; encoderForResultSchema on
the unwrapped sqlType instead expects the storage value directly, so a
non-empty createDataFrame with a normal JVM UDT can fail before reaching the
server. Could this conversion preserve exactly-once UDT serialization before
handing physical values to Arrow, with a focused ordinary-UDT object regression
case?
**Recommended change:** Preserve ordinary UDT serialization while lowering
CHAR/VARCHAR and UDT storage types for JVM Connect local-data transport, with
focused JVM UDT regression coverage.
**Why this works:** Derive an encoder/conversion plan that still observes
the requested UDT wrappers and invokes each UDT serializer exactly once, then
produces values conforming to the lowered Arrow schema instead of asking a
physical-schema encoder to consume the original user objects.
**Scope:** Separate external Row conversion from Arrow physical-schema
lowering in JVM Connect local-data creation.
**Compatibility:** Existing CHAR/VARCHAR policy handling and
PythonUserDefinedType storage-value input remain unchanged.
**Risks:** A conversion applied at both the encoder and Arrow boundary could
serialize a UDT twice. PythonUserDefinedType inputs that already contain
storage values must retain their current behavior.
**Constraints:** The requested logical schema must remain available to the
server. The values handed to Arrow must match the lowered physical schema.
**Success:** An ordinary JVM UDT user object accepted by the supplied schema
is serialized once and reaches Arrow as its SQL storage value. CHAR/VARCHAR
lowering and PythonUserDefinedType local-data behavior remain intact.
##########
sql/api/src/main/scala/org/apache/spark/sql/types/StructField.scala:
##########
@@ -96,18 +98,40 @@ case class StructField(
}
private def metadataJson: JValue = {
- val metadataJsonValue = metadata.jsonValue
- metadataJsonValue match {
- case JObject(fields) if collationMetadata.nonEmpty =>
- val collationFields = collationMetadata.map(kv => kv._1 ->
JString(kv._2)).toList
- JObject(fields :+ (DataType.COLLATIONS_METADATA_KEY ->
JObject(collationFields)))
-
- case _ => metadataJsonValue
+ metadata.jsonValue match {
+ case JObject(fields) =>
+ val withString =
+ if (stringCollationMetadata.nonEmpty) {
+ val collationFields =
+ stringCollationMetadata.map(kv => kv._1 -> JString(kv._2)).toList
+ fields :+ (DataType.COLLATIONS_METADATA_KEY ->
JObject(collationFields))
+ } else {
+ fields
+ }
+ val withBoth =
+ if (charVarcharCollationMetadata.nonEmpty) {
+ val collationFields =
+ charVarcharCollationMetadata.map(kv => kv._1 ->
JString(kv._2)).toList
+ withString :+
+ (DataType.CHAR_VARCHAR_COLLATIONS_METADATA_KEY ->
JObject(collationFields))
+ } else {
+ withString
+ }
+ JObject(withBoth)
+ case other => other
}
}
- /** Map of field path to collation name. */
- private lazy val collationMetadata: Map[String, String] = {
+ /** Map of field path to STRING collation name. */
Review Comment:
**Blocking (P1):** The compatibility traversal still stops when the field
type is a UDT. For the added CharValueUDT, its sqlType is emitted as inline
`char(4) collate ...` with no `__CHAR_VARCHAR_COLLATIONS` entry; a preceding
JVM reader then parses that sqlType through the old Python-UDT path and rejects
it instead of degrading to uncollated `char(4)`. Please collect and restore
constrained-string collations through UDT.sqlType in both Scala and Python, and
cover the preceding-reader shape.
See **Shared repair plan 1** in the review body.
##########
sql/api/src/main/scala/org/apache/spark/sql/types/DataType.scala:
##########
@@ -425,12 +493,16 @@ object DataType {
("name", JString(name)),
("nullable", JBool(nullable)),
("type", dataType: JValue)) =>
- val collationsMap = getCollationsMap(metadataFields)
- val metadataWithoutCollations =
- JObject(metadataFields.filterNot(_._1 == COLLATIONS_METADATA_KEY))
+ val collationsMap = getCollationsMap(metadataFields,
COLLATIONS_METADATA_KEY)
+ val charVarcharCollationsMap =
+ getCollationsMap(metadataFields, CHAR_VARCHAR_COLLATIONS_METADATA_KEY)
+ val metadataWithoutCollations = JObject(metadataFields.filterNot { field
=>
+ field._1 == COLLATIONS_METADATA_KEY ||
+ field._1 == CHAR_VARCHAR_COLLATIONS_METADATA_KEY
Review Comment:
**Non-blocking (P2):** `__CHAR_VARCHAR_COLLATIONS` was not previously
reserved from StructField metadata, but current readers always consume and
remove it. A field whose caller metadata already contains this key therefore
loses that value even for IntegerType; when generated collation metadata is
also present, Python overwrites it and Scala can emit duplicate members. Please
make the compatibility encoding collision-safe and verify that a pre-existing
caller value round-trips.
See **Shared repair plan 1** in the review body.
##########
sql/connect/common/src/main/scala/org/apache/spark/sql/connect/SparkSession.scala:
##########
@@ -150,7 +154,7 @@ class SparkSession private[sql] (
batchSizeCheckInterval = math.min(1024, maxChunkSizeRows))
try {
- val schemaBytes = encoder.schema.json.getBytes
+ val schemaBytes = relationSchema.json.getBytes
Review Comment:
**Non-blocking (P2):** The requested schema now travels separately in the
cached/multi-batch path, but the focused logical-versus-physical schema cases
never force this branch. A regression that writes `encoder.schema`, drops the
first schema chunk, or rejoins it incorrectly would leave the current empty and
one-row tests green. Please force a differing requested/Arrow schema through
cached or multi-batch transport and assert that the logical schema survives.
##########
python/pyspark/sql/types.py:
##########
@@ -2586,10 +2659,27 @@ def _parse_datatype_json_value( # type: ignore[return]
json_value: Union[dict, str],
fieldPath: str = "",
collationsMap: Optional[Dict[str, str]] = None,
+ charVarcharCollationsMap: Optional[Dict[str, str]] = None,
) -> DataType:
+ in_string = collationsMap is not None and fieldPath in collationsMap
Review Comment:
**Non-blocking (P2):** The new restoration-map validation is branch-local,
so earlier regex cases can return without checking it. For example,
`decimal(10,2)` with a `__CHAR_VARCHAR_COLLATIONS` entry is accepted and the
metadata is stripped here, while the JVM rejects that encoding as targeting a
non-CHAR/VARCHAR leaf. Could the metadata/type pairing be validated before any
atomic parser return so Scala and Python reject the same malformed schemas?
See **Shared repair plan 1** in the review body.
##########
python/pyspark/sql/connect/session.py:
##########
@@ -608,9 +652,13 @@ def createDataFrame(
spark_types: List[Optional[DataType]]
if isinstance(schema, StructType):
deduped_schema = cast(StructType,
_deduplicate_field_names(schema))
- spark_types = [field.dataType for field in
deduped_schema.fields]
+ arrow_compatible_schema = cast(
Review Comment:
**Non-blocking (P2):** This non-row branch now has its own
CHAR/VARCHAR-to-Arrow schema conversion, while the new end-to-end policy
coverage exercises only row collections. A pandas or pyarrow.Table path can
still fail client-side or accidentally transport the physical schema as
requested without affecting those tests. Please add constrained-string
createDataFrame cases for both pandas.DataFrame and pyarrow.Table that verify
Arrow encoding and server-side logical-schema policy handling.
##########
python/pyspark/sql/types.py:
##########
@@ -295,8 +298,11 @@ class StringType(AtomicType):
providerICU = "icu"
providers = [providerSpark, providerICU]
- def __init__(self, collation: str = "UTF8_BINARY"):
- self.collation = collation
+ __slots__ = ("_collation_explicit",)
+
+ def __init__(self, collation: str = _DEFAULT_STRING_COLLATION):
Review Comment:
**Non-blocking (P2):** Using a plain object sentinel changes the published
constructor signature from `collation='UTF8_BINARY'` to an opaque
address-bearing default in introspection and generated API docs. The omission
marker is useful for explicitness, but could it have a stable string-like
public representation so omitted and explicit UTF8_BINARY remain
distinguishable internally without changing the documented signature?
##########
python/pyspark/sql/tests/connect/test_connect_basic.py:
##########
@@ -491,17 +514,246 @@ def test_schema(self):
self._check_print_schema(query)
def test_char_varchar_result_schema(self):
- # SPARK-58794: Python Connect maps first-class CHAR/VARCHAR the same
as classic.
- query = "SELECT CAST('ab' AS CHAR(4)) AS c, CAST('cd' AS VARCHAR(6))
AS v"
+ # SPARK-59276: Python Connect maps first-class CHAR/VARCHAR the same
as classic.
+ query = """
+ SELECT
+ CAST('ab' AS CHAR(4)) AS c,
+ CAST('cd' AS VARCHAR(6)) AS v,
+ CAST('ef' AS CHAR(4) COLLATE UTF8_LCASE) AS collated_c,
+ CAST('gh' AS VARCHAR(6) COLLATE UNICODE_CI) AS collated_v
+ """
conf = {"spark.sql.charVarchar.standardSemantics.enabled": "true"}
with self.both_conf(conf):
classic_df = self.spark.sql(query)
connect_df = self.connect.sql(query)
self.assertEqual(classic_df.schema, connect_df.schema)
self.assertEqual(classic_df.schema["c"].dataType, CharType(4))
self.assertEqual(classic_df.schema["v"].dataType, VarcharType(6))
+ self.assertEqual(classic_df.schema["collated_c"].dataType,
CharType(4, "UTF8_LCASE"))
+ self.assertEqual(classic_df.schema["collated_v"].dataType,
VarcharType(6, "UNICODE_CI"))
self.assertEqual(classic_df.collect(), connect_df.collect())
- self.assertEqual(connect_df.collect(), [Row(c="ab ", v="cd")])
+ self.assertEqual(
+ connect_df.collect(),
+ [Row(c="ab ", v="cd", collated_c="ef ", collated_v="gh")],
+ )
+
+ def test_create_dataframe_with_char_varchar_schema(self):
+ schema = StructType(
+ [
+ StructField("c", CharType(4)),
+ StructField("explicit_c", CharType(4, "UTF8_BINARY")),
+ StructField("v", VarcharType(3, "UTF8_LCASE")),
+ StructField(
+ "nested",
+ StructType(
+ [
+ StructField("c", CharType(3, "UTF8_BINARY")),
+ StructField(
+ "values",
+ ArrayType(VarcharType(4, "UNICODE_CI"),
containsNull=False),
+ ),
+ StructField(
+ "lookup",
+ MapType(
+ CharType(3, "UTF8_LCASE"),
+ VarcharType(4, "UTF8_BINARY"),
+ valueContainsNull=False,
+ ),
+ ),
+ ]
+ ),
+ ),
+ ]
+ )
+ rows = [
+ (
+ "ab",
+ "cd",
+ "ef",
+ Row(c="x", values=["gh", "ij"], lookup={"k": "lm"}),
+ )
+ ]
+ standard_conf = {
+ "spark.sql.charVarchar.standardSemantics.enabled": "true",
+ "spark.sql.legacy.charVarcharAsString": "false",
+ }
+ with self.both_conf(standard_conf):
+ df = self.connect.createDataFrame(rows, schema)
+ self.assertEqual(df.schema, schema)
+ self.assertEqual(
+ df.collect(),
+ [
+ Row(
+ c="ab ",
+ explicit_c="cd ",
+ v="ef",
+ nested=Row(
+ c="x ",
+ values=["gh", "ij"],
+ lookup={"k ": "lm"},
+ ),
+ )
+ ],
+ )
+ empty = self.connect.createDataFrame([], schema)
+ self.assertEqual(empty.schema, schema)
+ self.assertEqual(empty.collect(), [])
+
+ legacy_conf = {
+ "spark.sql.charVarchar.standardSemantics.enabled": "false",
+ "spark.sql.legacy.charVarcharAsString": "true",
+ }
+ with self.both_conf(legacy_conf):
+ expected = StructType(
+ [
+ StructField("c", StringType()),
+ StructField("explicit_c", StringType("UTF8_BINARY")),
+ StructField("v", StringType("UTF8_LCASE")),
+ StructField(
+ "nested",
+ StructType(
+ [
+ StructField("c", StringType("UTF8_BINARY")),
+ StructField(
+ "values",
+ ArrayType(StringType("UNICODE_CI"),
containsNull=False),
+ ),
+ StructField(
+ "lookup",
+ MapType(
+ StringType("UTF8_LCASE"),
+ StringType("UTF8_BINARY"),
+ valueContainsNull=False,
+ ),
+ ),
+ ]
+ ),
+ ),
+ ]
+ )
+ df = self.connect.createDataFrame(rows, schema)
+ self.assertEqual(df.schema, expected)
+ self.assertEqual(
+ df.collect(),
+ [
+ Row(
+ c="ab",
+ explicit_c="cd",
+ v="ef",
+ nested=Row(
+ c="x",
+ values=["gh", "ij"],
+ lookup={"k": "lm"},
+ ),
+ )
+ ],
+ )
+ empty = self.connect.createDataFrame([], schema)
+ self.assertEqual(empty.schema, expected)
+ self.assertEqual(empty.collect(), [])
+
+ default_conf = {
+ "spark.sql.charVarchar.standardSemantics.enabled": "false",
+ "spark.sql.legacy.charVarcharAsString": "false",
+ }
+ with self.both_conf(default_conf):
+ for data in (rows, []):
+ with self.assertRaises(AnalysisException) as ctx:
+ self.connect.createDataFrame(data, schema).schema
+ self.check_error(
+ exception=ctx.exception,
+ errorClass="UNSUPPORTED_CHAR_OR_VARCHAR_AS_STRING",
+ )
+
+ def test_create_dataframe_with_udt_char_schema(self):
+ schema = StructType([StructField("value", CharValueUDT())])
+ rows = [(CharValue("ab"),)]
+
+ standard_conf = {
+ "spark.sql.charVarchar.standardSemantics.enabled": "true",
+ "spark.sql.legacy.charVarcharAsString": "false",
+ }
+ with self.both_conf(standard_conf):
+ populated = self.connect.createDataFrame(rows, schema)
+ self.assertEqual(populated.schema, schema)
+ self.assertEqual(populated.collect(), [Row(value=CharValue("ab
"))])
+ self.assertEqual(self.connect.createDataFrame([], schema).schema,
schema)
+
+ legacy_conf = {
+ "spark.sql.charVarchar.standardSemantics.enabled": "false",
+ "spark.sql.legacy.charVarcharAsString": "true",
+ }
+ with self.both_conf(legacy_conf):
+ expected = StructType([StructField("value",
StringType("UTF8_LCASE"))])
+ populated = self.connect.createDataFrame(rows, schema)
+ self.assertEqual(populated.schema, expected)
+ self.assertEqual(populated.collect(), [Row(value="ab")])
+ self.assertEqual(self.connect.createDataFrame([], schema).schema,
expected)
+
+ default_conf = {
+ "spark.sql.charVarchar.standardSemantics.enabled": "false",
+ "spark.sql.legacy.charVarcharAsString": "false",
+ }
+ with self.both_conf(default_conf):
+ for data in (rows, []):
+ with self.assertRaises(AnalysisException) as ctx:
+ self.connect.createDataFrame(data, schema).schema
+ self.check_error(
+ exception=ctx.exception,
+ errorClass="UNSUPPORTED_CHAR_OR_VARCHAR_AS_STRING",
+ )
+
+ def test_create_dataframe_with_explicit_binary_string_schema(self):
+ explicit_binary = StringType("UTF8_BINARY")
+ schema = StructType(
+ [
+ StructField("implicit", StringType()),
+ StructField("s", explicit_binary),
+ StructField(
+ "nested",
+ StructType(
+ [
+ StructField("s", explicit_binary),
+ StructField("a", ArrayType(explicit_binary)),
+ StructField(
+ "m",
+ MapType(explicit_binary, explicit_binary),
+ ),
+ ]
+ ),
+ ),
+ ]
+ )
+ rows = [
+ (
+ "implicit",
+ "direct",
+ Row(s="nested", a=["array"], m={"key": "value"}),
+ )
+ ]
+
+ populated = self.connect.createDataFrame(rows, schema)
Review Comment:
**Non-blocking (P2):** The broad StructType equality here does not observe
`_collation_explicit`, and only the top-level leaf gets an identity-sensitive
assertion. Returning implicit UTF8_BINARY from any nested struct, array,
map-key, or map-value restoration arm would still pass. Please inspect
explicitness at each changed nested arm so this test protects the state that
later compatibility serialization depends on.
##########
python/pyspark/sql/tests/connect/test_connect_basic.py:
##########
@@ -491,17 +491,28 @@ def test_schema(self):
self._check_print_schema(query)
def test_char_varchar_result_schema(self):
- # SPARK-58794: Python Connect maps first-class CHAR/VARCHAR the same
as classic.
- query = "SELECT CAST('ab' AS CHAR(4)) AS c, CAST('cd' AS VARCHAR(6))
AS v"
+ # SPARK-59276: Python Connect maps first-class CHAR/VARCHAR the same
as classic.
Review Comment:
Confirmed: Python Connect now lowers caller-provided CHAR/VARCHAR to
collation-preserving STRING for local Arrow conversion while retaining the
requested schema for server-side reconciliation.
<!-- SPARK_DEV_REVIEW_REPLY
{"feedback_id":"inline:4010878638","thread_id":"inline:4010878638","verdict_sha256":"0a61a499c58554b9add1cb9ff86029da1a2136fd17ae415ba19393ecd088428a"}
-->
##########
sql/api/src/main/scala/org/apache/spark/sql/types/StructField.scala:
##########
@@ -137,6 +137,8 @@ case class StructField(
}
private def isCollatedString(dt: DataType): Boolean = dt match {
+ case c: CharType => c.collation.isDefined
Review Comment:
Confirmed: direct and ordinary nested CHAR/VARCHAR schema JSON now uses
__CHAR_VARCHAR_COLLATIONS with uncollated constrained type text, while STRING
remains on __COLLATIONS.
<!-- SPARK_DEV_REVIEW_REPLY
{"feedback_id":"inline:4010878643","thread_id":"inline:4010878643","verdict_sha256":"0a61a499c58554b9add1cb9ff86029da1a2136fd17ae415ba19393ecd088428a"}
-->
##########
sql/connect/server/src/main/scala/org/apache/spark/sql/connect/planner/SparkConnectPlanner.scala:
##########
@@ -1641,9 +1647,35 @@ class SparkConnectPlanner(
.asInstanceOf[Project]
val proj = UnsafeProjection.create(project.projectList,
project.child.output)
- logical.LocalRelation(
- DataTypeUtils.toAttributes(schema),
- data.map(proj).map(_.copy()).toSeq)
+ def restoreFieldNames(actual: DataType, requested: DataType): DataType
=
+ (actual, requested) match {
+ case (_, requestedUdt: UserDefinedType[_]) => requestedUdt
+ case (StructType(actualFields), StructType(requestedFields)) =>
+ StructType(
+ actualFields.zip(requestedFields).map { case (actualField,
requestedField) =>
+ actualField.copy(
+ name = requestedField.name,
+ dataType = restoreFieldNames(actualField.dataType,
requestedField.dataType))
+ })
+ case (ArrayType(actualElement, containsNull),
ArrayType(requestedElement, _)) =>
+ ArrayType(restoreFieldNames(actualElement, requestedElement),
containsNull)
+ case (
+ MapType(actualKey, actualValue, valueContainsNull),
+ MapType(requestedKey, requestedValue, _)) =>
+ MapType(
+ restoreFieldNames(actualKey, requestedKey),
+ restoreFieldNames(actualValue, requestedValue),
+ valueContainsNull)
+ case _ => actual
+ }
+ val output = project.output.zip(schema.fields).map { case (attribute,
field) =>
+ AttributeReference(
+ field.name,
+ restoreFieldNames(attribute.dataType, field.dataType),
Review Comment:
Confirmed: local-relation reconstruction now keeps the requested explicit
UTF8_BINARY StringType when reconciliation has not selected a meaningfully
different leaf.
<!-- SPARK_DEV_REVIEW_REPLY
{"feedback_id":"inline:4015081820","thread_id":"inline:4015081820","verdict_sha256":"0a61a499c58554b9add1cb9ff86029da1a2136fd17ae415ba19393ecd088428a"}
-->
--
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]