This is an automated email from the ASF dual-hosted git repository.
cloud-fan pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/spark.git
The following commit(s) were added to refs/heads/master by this push:
new 1f80fa0fe491 [SPARK-57624][SQL] from_xml to variant should honor the
parse mode
1f80fa0fe491 is described below
commit 1f80fa0fe491c897a6e26252799986e2e195b5ae
Author: Matt Zhang <[email protected]>
AuthorDate: Thu Jul 2 11:18:33 2026 +0800
[SPARK-57624][SQL] from_xml to variant should honor the parse mode
### What changes were proposed in this pull request?
`from_xml(..., 'variant', map('mode', ...))` ignored the parse `mode`.
`XmlToStructsEvaluator.evaluate` called `StaxXmlParser.parseVariant` directly
for variant output, bypassing the `FailureSafeParser` the struct path uses, so
a malformed record aborted the whole job regardless of `mode`.
This routes variant output through `FailureSafeParser` like the struct
path. For variant output the evaluator builds a single-field `value: variant`
parser schema and sets an internal `XmlOptions.rootVariantType` flag;
`StaxXmlParser.doParseColumn` then parses the whole record as a root Variant
(as it already does for `singleVariantColumn`) and wraps it in a one-field row,
which the evaluator unwraps back to the variant. Reusing `doParseColumn` keeps
parse failures wrapped in `BadReco [...]
It also fixes `from_xml` to variant under whole-stage codegen:
`XmlToStructs.doGenCode` hardcast the result to `InternalRow` (a compile error
for variant output) and did not null-check the result (NPE on a
PERMISSIVE-rescued `null`). It now casts to the actual output type and
null-checks.
### Why are the changes needed?
`from_xml` to variant is unusable on input that contains any malformed
record: the documented `mode` option has no effect for variant output, so
PERMISSIVE cannot rescue bad records and a single bad row fails the whole query
(including streaming jobs). Separately, `from_xml` to variant fails to compile
under whole-stage codegen.
### Does this PR introduce _any_ user-facing change?
No.
### How was this patch tested?
New unit tests in `XmlFunctionsSuite`, both run under interpreted execution
and whole-stage codegen:
- "from_xml variant output honors the parse mode": a malformed record (an
illegal XML control char) plus a well-formed record, asserting PERMISSIVE
rescues the bad record to `null` and parses the good one, and FAILFAST throws.
- "from_xml variant output validates each record against
rowValidationXSDPath": an XSD-invalid record plus a valid one, asserting the
invalid record is rescued to `null` and the valid one parses.
### Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Code (Opus 4.8)
Closes #56681 from mzhang/oss-from-xml-variant-mode.
Lead-authored-by: Matt Zhang <[email protected]>
Co-authored-by: Matt Zhang <[email protected]>
Signed-off-by: Wenchen Fan <[email protected]>
---
.../expressions/xml/XmlExpressionEvalUtils.scala | 33 +++++++++----
.../sql/catalyst/expressions/xmlExpressions.scala | 17 ++++++-
.../spark/sql/catalyst/xml/StaxXmlParser.scala | 24 +++++-----
.../apache/spark/sql/catalyst/xml/XmlOptions.scala | 15 ++++--
.../org/apache/spark/sql/XmlFunctionsSuite.scala | 54 ++++++++++++++++++++++
5 files changed, 115 insertions(+), 28 deletions(-)
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/xml/XmlExpressionEvalUtils.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/xml/XmlExpressionEvalUtils.scala
index 438110d7acc4..b1fbccab532e 100644
---
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/xml/XmlExpressionEvalUtils.scala
+++
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/xml/XmlExpressionEvalUtils.scala
@@ -136,8 +136,14 @@ case class XmlToStructsEvaluator(
// This converts parsed rows to the desired output by the given schema.
@transient
- private lazy val converter =
- (rows: Iterator[InternalRow]) => if (rows.hasNext) rows.next() else null
+ private lazy val converter: Iterator[InternalRow] => Any = nullableSchema
match {
+ // For variant output the parser wraps the value in a single-field row
(see `parser`),
+ // so unwrap it back to the variant.
+ case _: VariantType =>
+ (rows: Iterator[InternalRow]) => if (rows.hasNext)
rows.next().getVariant(0) else null
+ case _ =>
+ (rows: Iterator[InternalRow]) => if (rows.hasNext) rows.next() else null
+ }
// Parser that parse XML strings as internal rows
@transient
@@ -147,11 +153,21 @@ case class XmlToStructsEvaluator(
throw QueryCompilationErrors.parseModeUnsupportedError("from_xml", mode)
}
- // The parser is only used when the input schema is StructType
- val schema = nullableSchema.asInstanceOf[StructType]
- ExprUtils.verifyColumnNameOfCorruptRecord(schema,
parsedOptions.columnNameOfCorruptRecord)
- val rawParser = new StaxXmlParser(schema, parsedOptions)
+ // For Variant output, wrap the value in a single-field struct and have
the parser emit a
+ // root Variant via `rootVariantType`; the struct path uses the schema
as-is. Routing both
+ // through StaxXmlParser.doParseColumn keeps the parse `mode` honored
(PERMISSIVE -> null,
+ // FAILFAST -> rethrow) and gives Variant output XSD validation as well.
+ val (schema, rawOptions) = nullableSchema match {
+ case _: VariantType =>
+ (StructType(Array(StructField("value", VariantType))),
+ new XmlOptions(options, timeZoneId.get, nameOfCorruptRecord,
rootVariantType = true))
+ case _ =>
+ val s = nullableSchema.asInstanceOf[StructType]
+ ExprUtils.verifyColumnNameOfCorruptRecord(s,
parsedOptions.columnNameOfCorruptRecord)
+ (s, parsedOptions)
+ }
+ val rawParser = new StaxXmlParser(schema, rawOptions)
val xsdSchema =
Option(parsedOptions.rowValidationXSDPath).map(ValidatorUtil.getSchema)
new FailureSafeParser[String](
@@ -163,10 +179,7 @@ case class XmlToStructsEvaluator(
final def evaluate(xml: UTF8String): Any = {
if (xml == null) return null
- nullableSchema match {
- case _: VariantType => StaxXmlParser.parseVariant(xml.toString,
parsedOptions)
- case _: StructType => converter(parser.parse(xml.toString))
- }
+ converter(parser.parse(xml.toString))
}
}
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/xmlExpressions.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/xmlExpressions.scala
index 1eb32791955a..5a91d3720375 100644
---
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/xmlExpressions.scala
+++
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/xmlExpressions.scala
@@ -18,7 +18,7 @@ package org.apache.spark.sql.catalyst.expressions
import org.apache.spark.sql.catalyst.analysis.TypeCheckResult
import
org.apache.spark.sql.catalyst.analysis.TypeCheckResult.{DataTypeMismatch,
TypeCheckSuccess}
-import org.apache.spark.sql.catalyst.expressions.codegen.{CodegenContext,
ExprCode}
+import org.apache.spark.sql.catalyst.expressions.codegen.{CodegenContext,
CodeGenerator, ExprCode}
import org.apache.spark.sql.catalyst.expressions.objects.StaticInvoke
import org.apache.spark.sql.catalyst.expressions.xml.{StructsToXmlEvaluator,
XmlExpressionEvalUtils, XmlToStructsEvaluator}
import org.apache.spark.sql.catalyst.util.DropMalformedMode
@@ -109,7 +109,20 @@ case class XmlToStructs(
override protected def doGenCode(ctx: CodegenContext, ev: ExprCode):
ExprCode = {
val expr = ctx.addReferenceObj("this", this)
- defineCodeGen(ctx, ev, input => s"(InternalRow)
$expr.nullSafeEval($input)")
+ // nullSafeEval returns an InternalRow for struct output and a VariantVal
for variant output.
+ // The variant result can be null (e.g. a malformed record rescued under
PERMISSIVE mode), so
+ // cast to the actual output type and null-check the result.
+ val resultType = CodeGenerator.javaType(dataType)
+ val result = ctx.freshName("xmlResult")
+ nullSafeCodeGen(ctx, ev, input =>
+ s"""
+ |$resultType $result = ($resultType) $expr.nullSafeEval($input);
+ |if ($result == null) {
+ | ${ev.isNull} = true;
+ |} else {
+ | ${ev.value} = $result;
+ |}
+ """.stripMargin)
}
override def inputTypes: Seq[AbstractDataType] =
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/xml/StaxXmlParser.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/xml/StaxXmlParser.scala
index f4eb443d95f5..82e127382b74 100644
---
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/xml/StaxXmlParser.scala
+++
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/xml/StaxXmlParser.scala
@@ -148,18 +148,18 @@ class StaxXmlParser(
xsdSchema.foreach { schema =>
schema.newValidator().validate(new StreamSource(new StringReader(xml)))
}
- options.singleVariantColumn match {
- case Some(_) =>
- // If the singleVariantColumn is specified, parse the entire xml
string as a Variant
- val v = StaxXmlParser.parseVariant(xml, options)
- Some(InternalRow(v))
- case _ =>
- // Otherwise, parse the xml string as Structs
- val parser = StaxXmlParserUtils.filteredReader(xml)
- val rootAttributes = StaxXmlParserUtils.gatherRootAttributes(parser)
- val result = Some(convertObject(parser, schema, rootAttributes))
- parser.close()
- result
+ if (options.singleVariantColumn.isDefined || options.rootVariantType) {
+ // If the singleVariantColumn is specified or the requested output is
a root Variant,
+ // parse the entire xml string as a Variant.
+ val v = StaxXmlParser.parseVariant(xml, options)
+ Some(InternalRow(v))
+ } else {
+ // Otherwise, parse the xml string as Structs
+ val parser = StaxXmlParserUtils.filteredReader(xml)
+ val rootAttributes = StaxXmlParserUtils.gatherRootAttributes(parser)
+ val result = Some(convertObject(parser, schema, rootAttributes))
+ parser.close()
+ result
}
} catch {
case e: SparkUpgradeException => throw e
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/xml/XmlOptions.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/xml/XmlOptions.scala
index 35e77189adb7..bd4da3b27c86 100644
---
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/xml/XmlOptions.scala
+++
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/xml/XmlOptions.scala
@@ -34,7 +34,10 @@ class XmlOptions(
val parameters: CaseInsensitiveMap[String],
private val defaultTimeZoneId: String,
private val defaultColumnNameOfCorruptRecord: String,
- private val rowTagRequired: Boolean)
+ private val rowTagRequired: Boolean,
+ // Set internally (e.g. by `from_xml` with a Variant schema) to parse each
record as a
+ // root Variant value. Not a user-facing option.
+ val rootVariantType: Boolean)
extends FileSourceOptions(parameters) with Logging {
import XmlOptions._
@@ -43,12 +46,14 @@ class XmlOptions(
parameters: Map[String, String] = Map.empty,
defaultTimeZoneId: String = SQLConf.get.sessionLocalTimeZone,
defaultColumnNameOfCorruptRecord: String =
SQLConf.get.columnNameOfCorruptRecord,
- rowTagRequired: Boolean = false) = {
+ rowTagRequired: Boolean = false,
+ rootVariantType: Boolean = false) = {
this(
CaseInsensitiveMap(parameters),
defaultTimeZoneId,
defaultColumnNameOfCorruptRecord,
- rowTagRequired)
+ rowTagRequired,
+ rootVariantType)
}
@@ -58,7 +63,8 @@ class XmlOptions(
parameters != null && parameters == other.parameters) &&
defaultTimeZoneId == other.defaultTimeZoneId &&
defaultColumnNameOfCorruptRecord ==
other.defaultColumnNameOfCorruptRecord &&
- rowTagRequired == other.rowTagRequired
+ rowTagRequired == other.rowTagRequired &&
+ rootVariantType == other.rootVariantType
case _ => false
}
@@ -67,6 +73,7 @@ class XmlOptions(
result = 31 * result + defaultTimeZoneId.hashCode()
result = 31 * result + defaultColumnNameOfCorruptRecord.hashCode()
result = 31 * result + (if (rowTagRequired) 1 else 0)
+ result = 31 * result + (if (rootVariantType) 1 else 0)
result
}
diff --git
a/sql/core/src/test/scala/org/apache/spark/sql/XmlFunctionsSuite.scala
b/sql/core/src/test/scala/org/apache/spark/sql/XmlFunctionsSuite.scala
index dd6bc024dd8e..73cdc7df139a 100644
--- a/sql/core/src/test/scala/org/apache/spark/sql/XmlFunctionsSuite.scala
+++ b/sql/core/src/test/scala/org/apache/spark/sql/XmlFunctionsSuite.scala
@@ -101,6 +101,60 @@ class XmlFunctionsSuite extends SharedSparkSession {
}
}
+ test("from_xml variant output honors the parse mode") {
+ // A raw control char (code 5, ENQ) in XML text is illegal in XML 1.0 and
the parser rejects
+ // it. Before the fix the variant path bypassed FailureSafeParser, so the
parse `mode` had no
+ // effect and a malformed record aborted the whole query.
+ val badRec = "<Event>ab" + 5.toChar + "cd</Event>"
+ val goodRec = "<Event><a>1</a></Event>"
+ val df = Seq(badRec, goodRec).toDF("value")
+ df.createOrReplaceTempView("from_xml_variant_mode")
+
+ // Exercise both the interpreted path and whole-stage codegen.
+ Seq("true", "false").foreach { wholeStage =>
+ withSQLConf(SQLConf.WHOLESTAGE_CODEGEN_ENABLED.key -> wholeStage) {
+ // PERMISSIVE: the good record parses, the malformed record is rescued
to null. Compare
+ // to_json of the variant so the expected value is concrete; key off
`value` since row
+ // order is not guaranteed.
+ checkAnswer(
+ spark.sql("SELECT value, to_json(from_xml(value, 'variant', " +
+ "map('rowTag','Event','mode','PERMISSIVE'))) AS json FROM
from_xml_variant_mode"),
+ Seq(Row(goodRec, "{\"a\":1}"), Row(badRec, null)))
+
+ // FAILFAST: the malformed record aborts the query.
+ checkError(
+ exception = intercept[SparkException] {
+ spark.sql("SELECT from_xml(value, 'variant', " +
+ "map('rowTag','Event','mode','FAILFAST')) AS d FROM
from_xml_variant_mode").collect()
+ },
+ condition = "MALFORMED_RECORD_IN_PARSING.WITHOUT_SUGGESTION",
+ parameters = Map("badRecord" -> "[null]", "failFastMode" ->
"FAILFAST"))
+ }
+ }
+ }
+
+ test("from_xml variant output validates each record against
rowValidationXSDPath") {
+ val xsdPath =
+
getTestResourcePath("test-data/xml-resources/basket.xsd").replace("file:/", "/")
+ // The first record satisfies basket.xsd; the second has an element not
allowed by the schema.
+ val valid =
"<basket><entry><key>1</key><value>fork</value></entry></basket>"
+ val invalid = "<basket><unexpected>x</unexpected></basket>"
+ val df = Seq(valid, invalid).toDF("value")
+ df.createOrReplaceTempView("from_xml_xsd")
+
+ Seq("true", "false").foreach { wholeStage =>
+ withSQLConf(SQLConf.WHOLESTAGE_CODEGEN_ENABLED.key -> wholeStage) {
+ val result = spark.sql("SELECT value, to_json(from_xml(value,
'variant', " +
+ s"map('rowTag','basket','rowValidationXSDPath','$xsdPath'))) AS json
FROM from_xml_xsd")
+ .collect().map(r => r.getString(0) -> r.getString(1)).toMap
+ assert(result(invalid) == null,
+ s"XSD-invalid record should be rescued to null
(wholeStageCodegen=$wholeStage)")
+ assert(result(valid) != null && result(valid).contains("fork"),
+ s"XSD-valid record should parse (wholeStageCodegen=$wholeStage),
got: ${result(valid)}")
+ }
+ }
+ }
+
test("from_xml with option (timestampFormat)") {
val df = Seq("""<ROW><time>26/08/2015 18:00</time></ROW>""").toDS()
val schema = new StructType().add("time", TimestampType)
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]