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 c585f81cb32c [SPARK-58133][SQL] XML schema inference must not skip
empty values to avoid data loss
c585f81cb32c is described below
commit c585f81cb32c32a2002264f89195aa92d080a4e6
Author: Wenchen Fan <[email protected]>
AuthorDate: Mon Jul 20 22:52:04 2026 +0800
[SPARK-58133][SQL] XML schema inference must not skip empty values to avoid
data loss
### What changes were proposed in this pull request?
In `XmlInferSchema.inferFrom`, stop skipping empty values during schema
inference. Previously the guard was:
```scala
if (value == null || value.isEmpty || value == options.nullValue) {
return typeSoFar
}
```
This PR drops the `value.isEmpty` term:
```scala
if (value == null || value == options.nullValue) {
return typeSoFar
}
```
so an empty value now falls through the type cascade to `StringType`. As a
result, a field mixing empty and numeric values widens to `StringType` instead
of being inferred as the numeric type.
### Why are the changes needed?
XML schema inference was copied from `CSVInferSchema.inferField`, which
does early-return on `field.isEmpty`. But the two datasources have a crucial
difference at parse time:
- CSV defaults `nullValue` to `""`, so an empty field is read as null by
the parser (`UnivocityParser.nullSafeDatum`). Its inference can safely skip
empty values because they never reach a numeric converter.
- XML defaults `nullValue` to `null`, so an empty value is **not** read as
null. If a column mixing empty and numeric values inferred `LongType`,
`StaxXmlParser` would call `convertTo("", LongType)`, which throws
`NumberFormatException`. In `PERMISSIVE` mode (the default), the
malformed-record error recovery can then silently consume sibling XML events,
**dropping records** — a silent data-loss bug.
Concretely, for input
```xml
<ROW><entry Code="001">first</entry><entry Code="002">second</entry></ROW>
<ROW><entry Code="">third</entry><entry Code="003">fourth</entry></ROW>
<ROW><entry Code="004">fifth</entry><entry Code="">sixth</entry></ROW>
```
`entry._Code` was inferred as `LongType` (from the numeric `Code` values,
empty skipped), and reading then dropped records when it hit the empty
`Code=""`. After this change `_Code` infers as `StringType` and all rows are
read.
By letting empty values fall through to `StringType`, inference stays
consistent with how the XML parser actually reads the data.
### Does this PR introduce _any_ user-facing change?
Yes. With the default `nullValue` (unset), a field that mixes empty and
typed (e.g. numeric) values is now inferred as `StringType` instead of the
typed type. This prevents the silent record-dropping described above. Fields
that are entirely typed, or that use a non-default `nullValue` covering the
empty token, are unaffected. Empty *elements* (`<c/>`) are still read as
`NullType` by the parser and are unaffected.
### How was this patch tested?
Added two tests to `XmlInferSchemaSuite`:
- `SPARK-58133: empty values are not skipped during inference (no data
loss)` — asserts that an attribute mixing empty and numeric values infers
`StringType` and that all rows/entries survive (no PERMISSIVE-mode record
dropping).
- `SPARK-58133: empty and numeric element values still infer via NullType`
— documents that empty elements keep inferring `LongType` (via the parser's
`NullType` path), pinning the element/attribute asymmetry.
Existing XML inference suites pass.
### Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Code (Anthropic Claude Opus)
Closes #57265 from cloud-fan/cloud-fan/xml-infer-empty-dataloss.
Authored-by: Wenchen Fan <[email protected]>
Signed-off-by: Wenchen Fan <[email protected]>
---
.../spark/sql/catalyst/xml/XmlInferSchema.scala | 12 +++++++-
.../xml/XmlInferSchemaTypeCastingSuite.scala | 15 ++++++---
.../datasources/xml/XmlInferSchemaSuite.scala | 36 ++++++++++++++++++++++
3 files changed, 57 insertions(+), 6 deletions(-)
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/xml/XmlInferSchema.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/xml/XmlInferSchema.scala
index 9df0ebcec7ae..d2a59498549a 100644
---
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/xml/XmlInferSchema.scala
+++
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/xml/XmlInferSchema.scala
@@ -371,7 +371,17 @@ class XmlInferSchema(private val options: XmlOptions,
private val caseSensitive:
// such values as null (see `StaxXmlParser`), so inference must skip them
too; otherwise a
// column that is entirely `nullValue`s (or mixes them with a typed value)
would infer the
// string content of the token instead of ignoring it.
- if (value == null || value.isEmpty || value == options.nullValue) {
+ //
+ // Unlike `CSVInferSchema.inferField`, an empty value is intentionally NOT
skipped here. The
+ // CSV datasource defaults `nullValue` to `""`, so an empty field is
already read as null by
+ // the parser (`UnivocityParser.nullSafeDatum`) and its inference can
safely skip it. XML's
+ // `nullValue` defaults to `null`, so an empty value is NOT read as null:
`StaxXmlParser`
+ // would call `convertTo("", <numericType>)`, which throws
`NumberFormatException`, and in
+ // PERMISSIVE mode the error recovery can silently drop sibling records.
To keep inference
+ // consistent with the parser, an empty value falls through the cascade to
`StringType`, so a
+ // field mixing empty and numeric values widens to `StringType` rather
than to the numeric
+ // type. See SPARK-58133.
+ if (value == null || value == options.nullValue) {
return typeSoFar
}
diff --git
a/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/xml/XmlInferSchemaTypeCastingSuite.scala
b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/xml/XmlInferSchemaTypeCastingSuite.scala
index 94cd261ae185..dcdd52a4031b 100644
---
a/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/xml/XmlInferSchemaTypeCastingSuite.scala
+++
b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/xml/XmlInferSchemaTypeCastingSuite.scala
@@ -37,7 +37,9 @@ class XmlInferSchemaTypeCastingSuite extends SparkFunSuite
with SQLHelper {
test("String field types are inferred correctly from null types") {
val inferSchema = newInferSchema(Map("timestampFormat" -> "yyyy-MM-dd
HH:mm:ss"))
- assert(inferSchema.inferFrom("", NullType) == NullType)
+ // An empty value carries no numeric/temporal content, so it falls through
the cascade to
+ // StringType (see `inferFrom`); only a genuinely null value preserves
`typeSoFar`.
+ assert(inferSchema.inferFrom("", NullType) == StringType)
assert(inferSchema.inferFrom(null, NullType) == NullType)
assert(inferSchema.inferFrom("100000000000", NullType) == LongType)
// XML infers integral values as LongType (there is no IntegerType
narrowing).
@@ -100,14 +102,17 @@ class XmlInferSchemaTypeCastingSuite extends
SparkFunSuite with SQLHelper {
assert(inferSchema.inferFrom("TRUEe", DoubleType) == StringType)
}
- test("Empty and null values keep the type inferred so far") {
+ test("Null values keep the type inferred so far; empty values widen to
String") {
val inferSchema = newInferSchema(Map.empty[String, String])
- // A genuinely empty/null value carries no type information, so
`typeSoFar` is preserved.
- assert(inferSchema.inferFrom("", NullType) == NullType)
- assert(inferSchema.inferFrom("", LongType) == LongType)
+ // A genuinely null value carries no type information, so `typeSoFar` is
preserved.
assert(inferSchema.inferFrom(null, DoubleType) == DoubleType)
assert(inferSchema.inferFrom(null, TimestampType) == TimestampType)
+ // Unlike CSV, XML inference does NOT skip empty values (see `inferFrom`
and SPARK-58133): the
+ // parser does not read "" as null under the default `nullValue`, so an
empty value falls
+ // through the cascade to StringType, widening any prior numeric/temporal
type to String.
+ assert(inferSchema.inferFrom("", NullType) == StringType)
+ assert(inferSchema.inferFrom("", LongType) == StringType)
}
test("A value matching the nullValue option keeps the type inferred so far")
{
diff --git
a/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/xml/XmlInferSchemaSuite.scala
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/xml/XmlInferSchemaSuite.scala
index e1579a53b9dd..e2e6809ad29c 100644
---
a/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/xml/XmlInferSchemaSuite.scala
+++
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/xml/XmlInferSchemaSuite.scala
@@ -652,6 +652,42 @@ class XmlInferSchemaSuite
checkAnswer(xmlDF, expectedAns)
}
+ test("SPARK-58133: empty values are not skipped during inference (no data
loss)") {
+ // An attribute that mixes empty ("") and numeric values across rows must
infer as StringType,
+ // not LongType. With the default nullValue (null), the parser does NOT
read "" as null, so if
+ // the column inferred LongType, convertTo("", LongType) would throw
NumberFormatException and
+ // PERMISSIVE-mode error recovery could silently drop sibling records.
Inference therefore lets
+ // an empty value fall through the cascade to StringType, keeping it
consistent with the parser.
+ val rows = Seq(
+ """<ROW><entry Code="001">first</entry><entry
Code="002">second</entry></ROW>""",
+ """<ROW><entry Code="">third</entry><entry
Code="003">fourth</entry></ROW>""",
+ """<ROW><entry Code="004">fifth</entry><entry
Code="">sixth</entry></ROW>""")
+ val df = readData(rows)
+ val entryStruct = df.schema("entry").dataType match {
+ case ArrayType(s: StructType, _) => s
+ case s: StructType => s
+ case other => fail(s"Expected ArrayType(StructType) or StructType for
`entry`, got $other")
+ }
+ assert(entryStruct("_Code").dataType === StringType)
+ // All three rows (six entries) must survive -- the empty attribute must
not trigger the
+ // PERMISSIVE error-recovery path that drops sibling records.
+ assert(df.count() === 3)
+ assert(df.selectExpr("sum(size(entry))").head().getLong(0) === 6)
+ }
+
+ test("SPARK-58133: empty and numeric element values still infer via
NullType") {
+ // Empty *elements* (<c/>) are read as NullType by the parser's EndElement
branch, so a column
+ // mixing empty and numeric elements infers LongType and reads the empty
element as null. This
+ // documents the element/attribute asymmetry (only attribute empties fall
through to String).
+ val rows = Seq(
+ """<ROW><c>1</c></ROW>""",
+ """<ROW><c/></ROW>""",
+ """<ROW><c>3</c></ROW>""")
+ val df = readData(rows)
+ assert(df.schema("c").dataType === LongType)
+ checkAnswer(df, Seq(Row(1L), Row(null), Row(3L)))
+ }
+
test("TIME type inference") {
val xmlString = Seq("""<ROW><t>13:31:24.123456</t></ROW>""")
val df = readData(xmlString)
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]