This is an automated email from the ASF dual-hosted git repository.
MaxGekk 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 d76b07926ea3 [SPARK-57802][SQL] Make XML schema inference incremental,
aligning with CSV
d76b07926ea3 is described below
commit d76b07926ea31988114769c618279d504e3ad805
Author: Wenchen Fan <[email protected]>
AuthorDate: Thu Jul 2 12:48:24 2026 +0200
[SPARK-57802][SQL] Make XML schema inference incremental, aligning with CSV
### What changes were proposed in this pull request?
This makes XML schema inference **incremental**: instead of inferring each
primitive value from scratch and merging afterwards, each value is refined
starting from the type already inferred for that field so far. This aligns XML
with the CSV datasource (`CSVInferSchema.inferField`), which already works this
way.
Concretely:
- `XmlInferSchema.inferFrom(datum, typeSoFar)` dispatches on `typeSoFar`
and enters a `tryParse*` cascade at the parser matching it, so a field already
known to be (say) `Double` is not re-checked against `Long`. The result is
reconciled with `typeSoFar` via `compatibleType`, keeping the type lattice
monotonic (a field's type can only widen, never narrow).
- The RDD driver (`infer(xml: RDD[String])`) threads the schema merged so
far in each partition back into the next record's inference as a hint. The hint
is propagated down through `inferObject`/`inferField` per field (unwrapping
array/value-tag wrappers), mirroring how the field's type is accumulated.
- A new config,
`spark.sql.xml.schemaInference.incrementalTypeCasting.enabled` (default
**true**), gates the behavior. Setting it to false restores the legacy
per-record inference. The old `is*`-predicate helpers are replaced by the
`tryParse*` cascade.
This is **behavior-preserving**: the inferred schema is unchanged. The
change avoids redundant parse attempts once a field has widened, and reduces
divergence between the CSV, JSON, and XML inference implementations. The config
is provided as a kill switch should any corner-case difference surface in the
field.
### Why are the changes needed?
XML was the only text-based datasource still inferring each value from
scratch on every row. CSV (and, structurally, JSON) already refine from the
type-so-far. Aligning XML:
- avoids redundant work (once a field is `Double`, later values no longer
re-run the `Long`/`Decimal` checks);
- makes inference monotonic and easier to reason about;
- reduces gratuitous divergence between the three inference code paths.
### Does this PR introduce _any_ user-facing change?
No change to inferred schemas. A new internal SQL config
`spark.sql.xml.schemaInference.incrementalTypeCasting.enabled` (default true)
is added as a kill switch for the new inference path.
### How was this patch tested?
- New unit suite `XmlInferSchemaTypeCastingSuite` (catalyst) tests
`inferFrom(value, typeSoFar)` directly across the type lattice: widening,
incompatible-value fallback to `StringType`,
timestamp/date/boolean-from-other-types, and empty/null handling. Mirrors
`CSVInferSchemaSuite`.
- New end-to-end test in `XmlInferSchemaSuite` asserts the incremental path
(flag on) and the legacy batch path (flag off) produce **identical** schemas
across mixed numerics, nested structs, repeated elements (arrays), attributes,
value tags, and cross-row widening.
- Existing `XmlInferSchemaSuite`, `XmlSuite`, and `XmlVariantSuite` pass
unchanged with the flag defaulted on.
### Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Code (Anthropic Claude Opus)
Closes #56919 from cloud-fan/xml-incremental-inference.
Authored-by: Wenchen Fan <[email protected]>
Signed-off-by: Max Gekk <[email protected]>
---
.../spark/sql/catalyst/xml/XmlInferSchema.scala | 357 ++++++++++++++++-----
.../org/apache/spark/sql/internal/SQLConf.scala | 18 ++
.../xml/XmlInferSchemaTypeCastingSuite.scala | 165 ++++++++++
.../datasources/xml/XmlInferSchemaSuite.scala | 84 +++++
4 files changed, 539 insertions(+), 85 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 2471cfc735bd..fb534062ec28 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
@@ -33,7 +33,7 @@ import scala.xml.SAXException
import org.apache.hadoop.hdfs.BlockMissingException
import org.apache.hadoop.security.AccessControlException
-import org.apache.spark.SparkIllegalArgumentException
+import org.apache.spark.{SparkException, SparkIllegalArgumentException}
import org.apache.spark.internal.Logging
import org.apache.spark.rdd.RDD
import org.apache.spark.sql.catalyst.analysis.TypeCoercion
@@ -77,6 +77,8 @@ class XmlInferSchema(private val options: XmlOptions, private
val caseSensitive:
private val isTimeTypeEnabled = SQLConf.get.isTimeTypeEnabled
+ private val incrementalTypeCasting =
SQLConf.get.xmlSchemaInferenceIncrementalTypeCasting
+
override def equals(obj: Any): Boolean = obj match {
case other: XmlInferSchema =>
options == other.options &&
@@ -123,9 +125,27 @@ class XmlInferSchema(private val options: XmlOptions,
private val caseSensitive:
val mergedTypesFromPartitions = schemaData.mapPartitions { iter =>
val xsdSchema =
Option(options.rowValidationXSDPath).map(ValidatorUtil.getSchema)
- iter.flatMap { xml =>
- infer(xml, xsdSchema)
- }.reduceOption(compatibleType(caseSensitive, options.valueTag)).iterator
+ if (incrementalTypeCasting) {
+ // Incremental inference: carry the schema merged so far in this
partition and feed it
+ // back as a hint into the next record's inference, so each field's
type is refined
+ // rather than re-derived from scratch. The final partition type is
the last (fully
+ // merged) schema, matching the batch path's fold up to
`compatibleType`'s associativity.
+ var schemaSoFar: DataType = StructType(Nil)
+ iter.flatMap { xml =>
+ val schemaHints = schemaSoFar match {
+ case st: StructType => st
+ case _ => StructType(Nil)
+ }
+ infer(xml, xsdSchema, schemaHints).map { dataType =>
+ schemaSoFar = compatibleType(caseSensitive,
options.valueTag)(schemaSoFar, dataType)
+ schemaSoFar
+ }
+ }.reduceOption((_, latest) => latest).iterator
+ } else {
+ iter.flatMap { xml =>
+ infer(xml, xsdSchema)
+ }.reduceOption(compatibleType(caseSensitive,
options.valueTag)).iterator
+ }
}
// Here we manually submit a fold-like Spark job, so that we can set the
SQLConf when running
@@ -150,7 +170,10 @@ class XmlInferSchema(private val options: XmlOptions,
private val caseSensitive:
}
}
- def infer(xml: String, xsdSchema: Option[Schema] = None): Option[DataType] =
{
+ def infer(
+ xml: String,
+ xsdSchema: Option[Schema],
+ schemaHints: StructType): Option[DataType] = {
var parser: XMLEventReader = null
try {
val xsd =
xsdSchema.orElse(Option(options.rowValidationXSDPath).map(ValidatorUtil.getSchema))
@@ -159,7 +182,7 @@ class XmlInferSchema(private val options: XmlOptions,
private val caseSensitive:
}
parser = StaxXmlParserUtils.filteredReader(xml)
val rootAttributes = StaxXmlParserUtils.gatherRootAttributes(parser)
- val schema = Some(inferObject(parser, rootAttributes))
+ val schema = Some(inferObject(parser, rootAttributes, Some(schemaHints)))
parser.close()
schema
} catch {
@@ -194,6 +217,9 @@ class XmlInferSchema(private val options: XmlOptions,
private val caseSensitive:
}
}
+ def infer(xml: String, xsdSchema: Option[Schema] = None): Option[DataType] =
+ infer(xml, xsdSchema, StructType(Nil))
+
def inferFromReaders(recordReader: RDD[StaxXMLRecordReader]): StructType = {
val sampledRecordReader = if (options.samplingRatio < 1.0) {
recordReader.sample(withReplacement = false, options.samplingRatio, 1)
@@ -312,41 +338,80 @@ class XmlInferSchema(private val options: XmlOptions,
private val caseSensitive:
}
private def inferFrom(datum: String): DataType = {
+ // Start type inference from scratch, without any prior knowledge of the
field's type.
+ inferFrom(datum, NullType)
+ }
+
+ /**
+ * Infer the type of a primitive value, refining a type already inferred for
the same field
+ * from previous values.
+ *
+ * Rather than probing every candidate type from scratch on every value (the
historical
+ * approach), this dispatches on `typeSoFar` -- the type accumulated for
this field so far --
+ * and only attempts to widen it. Given a value already known to be
`Double`, for example,
+ * there is no point checking whether it is a `Long`, as the merged type can
only be `Double`
+ * or wider. This mirrors the schema inference used by the CSV datasource
+ * (`CSVInferSchema.inferField`) and keeps the type lattice monotonic: each
value can only
+ * hold the type where it is or widen it, never narrow it. `compatibleType`
reconciles the
+ * newly inferred type with `typeSoFar`, falling back to `StringType` (the
top type) when they
+ * are incompatible.
+ */
+ private[xml] def inferFrom(datum: String, typeSoFar: DataType): DataType = {
val value = if (datum != null && options.ignoreSurroundingSpaces) {
datum.trim()
} else {
datum
}
- if (options.inferSchema) {
- lazy val decimalTry = tryParseDecimal(value)
- lazy val timestampNTZTry = tryParseTimestampNTZ(value)
- value match {
- case null => NullType
- case v if v.isEmpty => NullType
- case v if isLong(v) => LongType
- case v if options.prefersDecimal && decimalTry.isDefined =>
decimalTry.get
- case v if isDouble(v) => DoubleType
- case v if isBoolean(v) => BooleanType
- case v if isTimeTypeEnabled && isTime(v) =>
TimeType(TimeType.DEFAULT_PRECISION)
- case v if isDate(v) => DateType
- case v if timestampNTZTry.isDefined => timestampNTZTry.get
- case v if isTimestamp(v) => TimestampType
- case _ => StringType
- }
- } else {
- StringType
- }
+ if (!options.inferSchema) {
+ return StringType
+ }
+ if (value == null || value.isEmpty) {
+ return typeSoFar
+ }
+
+ val inferredType = typeSoFar match {
+ // For a numeric type inferred so far, re-enter the cascade at its top
(`tryParseLong`)
+ // rather than at the narrower numeric parser. Entering at
`tryParseDecimal` would infer an
+ // integer value directly as a narrow `Decimal` (e.g. "5" ->
Decimal(1,0)) instead of the
+ // `Long` that from-scratch inference produces; `compatibleType` then
merges those to
+ // different Decimal precisions (Decimal(5,2) vs Decimal(22,2)), which
would make the
+ // inferred type differ from the legacy path and depend on row order.
Re-entering at the top
+ // yields the same representative as from-scratch inference, so the
merged type matches.
+ case NullType | LongType | DoubleType | _: DecimalType =>
tryParseLong(value)
+ case BooleanType => tryParseBoolean(value)
+ // For a temporal type inferred so far, re-enter the cascade at the top
of the temporal
+ // sub-cascade (`tryParseTime`, which flows Time -> Date -> TimestampNTZ
-> Timestamp)
+ // rather than at the narrower temporal parser. Entering at
`tryParseTimestamp` for a
+ // `Timestamp` type-so-far, for example, would fail to parse a date-only
value (when
+ // `timestampFormat` requires a time part) and fall through to `String`;
`compatibleType`
+ // then merges `Timestamp` with `String` to `String`, whereas
from-scratch inference yields
+ // `Date` which widens with `Timestamp` to `Timestamp` via
`findWiderDateTimeType`. That
+ // would make the inferred type differ from the legacy path and depend
on row order.
+ // Re-entering at the top yields the same representative as from-scratch
inference (which
+ // reaches the temporal parsers through `tryParseDouble`), so the merged
type matches.
+ case _: TimeType | DateType | TimestampNTZType | _:
TimestampNTZNanosType | TimestampType =>
+ tryParseTime(value)
+ case StringType => StringType
+ case other: DataType =>
+ throw SparkException.internalError(s"Unexpected data type $other")
+ }
+ compatibleType(caseSensitive, options.valueTag)(typeSoFar, inferredType)
}
- private def inferField(parser: XMLEventReader): DataType = {
+ private def inferField(
+ parser: XMLEventReader,
+ dataTypeHintOpt: Option[DataType] = None): DataType = {
parser.peek match {
case _: EndElement =>
parser.nextEvent()
NullType
- case _: StartElement => inferObject(parser)
+ case _: StartElement =>
+ inferObject(parser, schemaHintsOpt = structTypeHint(dataTypeHintOpt))
case _: Characters =>
- val structType = inferObject(parser).asInstanceOf[StructType]
+ val structType =
+ inferObject(parser, schemaHintsOpt =
structTypeHintWithValueTag(dataTypeHintOpt))
+ .asInstanceOf[StructType]
structType match {
case _ if structType.fields.isEmpty =>
NullType
@@ -369,7 +434,8 @@ class XmlInferSchema(private val options: XmlOptions,
private val caseSensitive:
*/
private def inferObject(
parser: XMLEventReader,
- rootAttributes: Array[Attribute] = Array.empty): DataType = {
+ rootAttributes: Array[Attribute] = Array.empty,
+ schemaHintsOpt: Option[StructType] = None): DataType = {
/**
* Retrieves the field name with respect to the case sensitivity setting.
* We pick the first name we encountered.
@@ -393,12 +459,22 @@ class XmlInferSchema(private val options: XmlOptions,
private val caseSensitive:
val nameToDataType =
collection.mutable.TreeMap.empty[String,
DataType](caseSensitivityOrdering)
+ // When incremental inference is on, `schemaHintsOpt` carries the schema
inferred for this
+ // object from previous records. `fieldToHint` maps each field name to its
already-inferred
+ // type so it can be threaded into `inferFrom`/`inferField` as the
starting point, refining
+ // it rather than re-deriving from scratch. Empty for the batch path
(`schemaHintsOpt` None).
+ val fieldToHint: Map[String, DataType] = schemaHintsOpt match {
+ case Some(schema) if caseSensitive => schema.nameToDataType
+ case Some(schema) => schema.nameToDataTypeCaseInsensitive
+ case None => Map.empty
+ }
+
// If there are attributes, then we should process them first.
val rootValuesMap =
StaxXmlParserUtils.convertAttributesToValuesMap(rootAttributes, options)
rootValuesMap.foreach {
case (f, v) =>
- addOrUpdateType(nameToDataType, f, inferFrom(v))
+ addOrUpdateType(nameToDataType, f, inferFrom(v,
matchingPrimitiveHint(fieldToHint.get(f))))
}
var shouldStop = false
while (!shouldStop) {
@@ -407,14 +483,16 @@ class XmlInferSchema(private val options: XmlOptions,
private val caseSensitive:
val attributes = e.getAttributes.asScala.toArray
val valuesMap =
StaxXmlParserUtils.convertAttributesToValuesMap(attributes, options)
val field = StaxXmlParserUtils.getName(e.asStartElement.getName,
options)
- val inferredType = inferField(parser) match {
+ val fieldHintOpt = fieldToHint.get(field)
+ val inferredType = inferField(parser, fieldHintOpt) match {
case st: StructType if valuesMap.nonEmpty =>
// Merge attributes to the field
val nestedBuilder = ArrayBuffer[StructField]()
nestedBuilder ++= st.fields
valuesMap.foreach {
case (f, v) =>
- nestedBuilder += StructField(f, inferFrom(v), nullable =
true)
+ val attrHint = matchingAttributeHint(fieldHintOpt, f)
+ nestedBuilder += StructField(f, inferFrom(v, attrHint),
nullable = true)
}
StructType(nestedBuilder.sortBy(_.name).toArray)
@@ -426,7 +504,8 @@ class XmlInferSchema(private val options: XmlOptions,
private val caseSensitive:
}
valuesMap.foreach {
case (f, v) =>
- nestedBuilder += StructField(f, inferFrom(v), nullable =
true)
+ val attrHint = matchingAttributeHint(fieldHintOpt, f)
+ nestedBuilder += StructField(f, inferFrom(v, attrHint),
nullable = true)
}
StructType(nestedBuilder.sortBy(_.name).toArray)
@@ -437,7 +516,8 @@ class XmlInferSchema(private val options: XmlOptions,
private val caseSensitive:
case c: Characters if !c.isWhiteSpace =>
// This is a value tag
- val valueTagType = inferFrom(c.getData)
+ val valueTagType =
+ inferFrom(c.getData,
matchingPrimitiveHint(fieldToHint.get(options.valueTag)))
addOrUpdateType(nameToDataType, options.valueTag, valueTagType)
case _: EndElement | _: EndDocument =>
@@ -453,87 +533,177 @@ class XmlInferSchema(private val options: XmlOptions,
private val caseSensitive:
}.toList.sortBy(_.name))
}
+ // --- Helpers for incremental inference: extracting the type hint for a
nested field ---
+ //
+ // When incremental inference threads a previously-inferred schema back in,
a field's hint may
+ // be wrapped (a leaf value gets represented as a single-`valueTag` struct,
and repeated elements
+ // as an array). These helpers unwrap the hint to the shape the next
inference step expects.
+
/**
- * Helper method that checks and cast string representation of a numeric
types.
+ * Reduce a field's hint to the primitive type to feed into `inferFrom` for
a leaf value.
+ * A leaf value may have been inferred previously either as a bare
primitive, or -- when the
+ * element also carried attributes -- as a struct with a `valueTag` field,
possibly wrapped in
+ * an array for repeated elements. Anything else (a struct without a value
tag) offers no usable
+ * primitive hint, so we fall back to `NullType` (i.e. infer from scratch).
*/
- private def isBoolean(value: String): Boolean = {
- value.toLowerCase(Locale.ROOT) match {
- case "true" | "false" => true
- case _ => false
+ private def matchingPrimitiveHint(dataTypeHintOpt: Option[DataType]):
DataType = {
+ def unwrap(dt: DataType): DataType = dt match {
+ case ArrayType(elementType, _) => unwrap(elementType)
+ case st: StructType if st.fieldNames.contains(options.valueTag) =>
+ st(options.valueTag).dataType match {
+ case ArrayType(elementType, _) => elementType
+ case other => other
+ }
+ case _: StructType => NullType
+ case primitive => primitive
+ }
+ dataTypeHintOpt.map(unwrap).map {
+ // A value tag can only hold a primitive; a nested complex type here
means the hint is not
+ // usable as a primitive starting point, so infer from scratch.
+ case _: StructType | _: MapType => NullType
+ case primitive => primitive
+ }.getOrElse(NullType)
+ }
+
+ /**
+ * Extract the hint for a named attribute from a field's hint. Attributes
live as fields of the
+ * struct (or the struct element of an array) the field was inferred as.
+ */
+ private def matchingAttributeHint(
+ dataTypeHintOpt: Option[DataType],
+ attributeName: String): DataType = {
+ val structOpt = dataTypeHintOpt match {
+ case Some(st: StructType) => Some(st)
+ case Some(ArrayType(st: StructType, _)) => Some(st)
+ case _ => None
+ }
+ matchingPrimitiveHint(structOpt.flatMap(_.fields.find(_.name ==
attributeName).map(_.dataType)))
+ }
+
+ /** Reduce a field's hint to a struct hint for a nested object (an array
uses its element). */
+ private def structTypeHint(dataTypeHintOpt: Option[DataType]):
Option[StructType] =
+ dataTypeHintOpt match {
+ case Some(st: StructType) => Some(st)
+ case Some(ArrayType(st: StructType, _)) => Some(st)
+ case _ => None
+ }
+
+ /**
+ * Like `structTypeHint`, but for a leaf-or-mixed element whose previous
inference may have been a
+ * bare primitive: wrap it as a single-`valueTag` struct so the value tag's
type is still hinted.
+ */
+ private def structTypeHintWithValueTag(dataTypeHintOpt: Option[DataType]):
Option[StructType] =
+ dataTypeHintOpt match {
+ case Some(st: StructType) => Some(st)
+ case Some(ArrayType(st: StructType, _)) => Some(st)
+ case Some(primitive) => Some(new StructType().add(options.valueTag,
primitive))
+ case None => None
+ }
+
+ /**
+ * The `tryParse*` methods below form a cascade: each attempts to parse the
value as its own
+ * type and, on failure, delegates to the next-wider type in the inference
lattice
+ * (Long -> Decimal -> Double -> Time/Date -> TimestampNTZ -> Timestamp ->
Boolean -> String).
+ * Entering the cascade at the parser matching `typeSoFar` (see `inferFrom`)
therefore only ever
+ * widens the type, never narrows it. This mirrors `CSVInferSchema`'s
`tryParse*` chain.
+ */
+ private def tryParseLong(field: String): DataType = {
+ val signSafeValue = if (field.startsWith("+") || field.startsWith("-")) {
+ field.substring(1)
+ } else {
+ field
+ }
+ // A little shortcut to avoid trying many formatters in the common case
that
+ // the input isn't a number. All built-in formats will start with a digit.
+ if (signSafeValue.isEmpty || !Character.isDigit(signSafeValue.head)) {
+ return tryParseDecimal(field)
+ }
+ if ((allCatch opt signSafeValue.toLong).isDefined) {
+ LongType
+ } else {
+ tryParseDecimal(field)
}
}
- private def tryParseDecimal(value: String): Option[DataType] = {
- val signSafeValue = if (value.startsWith("+") || value.startsWith("-")) {
- value.substring(1)
+ private def tryParseDecimal(field: String): DataType = {
+ val signSafeValue = if (field.startsWith("+") || field.startsWith("-")) {
+ field.substring(1)
} else {
- value
+ field
}
// A little shortcut to avoid trying many formatters in the common case
that
// the input isn't a decimal. All built-in formats will start with a digit
or period.
if (signSafeValue.isEmpty ||
!(Character.isDigit(signSafeValue.head) || signSafeValue.head == '.')) {
- return None
+ return tryParseDouble(field)
}
// Rule out strings ending in D or F, as they will parse as double but
should be disallowed
if (signSafeValue.last match {
case 'd' | 'D' | 'f' | 'F' => true
case _ => false
}) {
- return None
+ return tryParseDouble(field)
}
-
- allCatch opt {
- val bigDecimal = decimalParser(value)
- DecimalType(Math.max(bigDecimal.precision, bigDecimal.scale),
bigDecimal.scale)
+ val decimalTry = if (options.prefersDecimal) {
+ allCatch opt {
+ val bigDecimal = decimalParser(field)
+ DecimalType(Math.max(bigDecimal.precision, bigDecimal.scale),
bigDecimal.scale)
+ }
+ } else {
+ None
}
+ decimalTry.getOrElse(tryParseDouble(field))
}
- private def isDouble(value: String): Boolean = {
- val signSafeValue = if (value.startsWith("+") || value.startsWith("-")) {
- value.substring(1)
+ private def tryParseDouble(field: String): DataType = {
+ val signSafeValue = if (field.startsWith("+") || field.startsWith("-")) {
+ field.substring(1)
} else {
- value
+ field
}
// A little shortcut to avoid trying many formatters in the common case
that
// the input isn't a double. All built-in formats will start with a digit
or period.
- if (signSafeValue.isEmpty ||
- !(Character.isDigit(signSafeValue.head) || signSafeValue.head == '.')) {
- return false
- }
- // Rule out strings ending in D or F, as they will parse as double but
should be disallowed
- if (signSafeValue.last match {
- case 'd' | 'D' | 'f' | 'F' => true
- case _ => false
- }) {
- return false
+ val isDouble =
+ if (signSafeValue.isEmpty ||
+ !(Character.isDigit(signSafeValue.head) || signSafeValue.head == '.'))
{
+ false
+ } else if (signSafeValue.last match {
+ // Rule out strings ending in D or F,
+ // as they will parse as double but should be disallowed
+ case 'd' | 'D' | 'f' | 'F' => true
+ case _ => false
+ }) {
+ false
+ } else {
+ (allCatch opt signSafeValue.toDouble).isDefined
+ }
+ if (isDouble) {
+ DoubleType
+ } else if (isTimeTypeEnabled && isTime(field)) {
+ // TIME is tried ahead of date/timestamp, matching the ordering of the
previous cascade.
+ TimeType(TimeType.DEFAULT_PRECISION)
+ } else {
+ tryParseDate(field)
}
- (allCatch opt signSafeValue.toDouble).isDefined
}
- private def isLong(value: String): Boolean = {
- val signSafeValue = if (value.startsWith("+") || value.startsWith("-")) {
- value.substring(1)
+ private def tryParseTime(field: String): DataType = {
+ if (isTimeTypeEnabled && isTime(field)) {
+ TimeType(TimeType.DEFAULT_PRECISION)
} else {
- value
+ tryParseDate(field)
}
- // A little shortcut to avoid trying many formatters in the common case
that
- // the input isn't a number. All built-in formats will start with a digit.
- if (signSafeValue.isEmpty || !Character.isDigit(signSafeValue.head)) {
- return false
- }
- (allCatch opt signSafeValue.toLong).isDefined
}
- private def isTimestamp(value: String): Boolean = {
- try {
- timestampFormatter.parseOptional(value).isDefined
- } catch {
- case _: IllegalArgumentException => false
+ private def tryParseDate(field: String): DataType = {
+ if ((allCatch opt dateFormatter.parse(field)).isDefined) {
+ DateType
+ } else {
+ tryParseTimestampNTZ(field)
}
}
- private def tryParseTimestampNTZ(field: String): Option[DataType] = {
+ private def tryParseTimestampNTZ(field: String): DataType = {
// We can only parse the value as TimestampNTZType if it does not have
zone-offset or
// time-zone component and can be parsed with the timestamp formatter.
// Otherwise, it is likely to be a timestamp with timezone.
@@ -548,18 +718,35 @@ class XmlInferSchema(private val options: XmlOptions,
private val caseSensitive:
timestampNTZFormatter.parseWithoutTimeZoneNanosOptional(field, 9,
false)
.exists(_.nanosWithinMicro != 0)
if (hasSubMicro) {
- return Some(TimestampNTZNanosType(9))
+ return TimestampNTZNanosType(9)
}
- return Some(timestampType)
+ return timestampType
}
} catch {
case _: Exception =>
}
- None
+ tryParseTimestamp(field)
+ }
+
+ private def tryParseTimestamp(field: String): DataType = {
+ val isTimestamp =
+ try {
+ timestampFormatter.parseOptional(field).isDefined
+ } catch {
+ case _: IllegalArgumentException => false
+ }
+ if (isTimestamp) {
+ TimestampType
+ } else {
+ tryParseBoolean(field)
+ }
}
- private def isDate(value: String): Boolean = {
- (allCatch opt dateFormatter.parse(value)).isDefined
+ private def tryParseBoolean(field: String): DataType = {
+ field.toLowerCase(Locale.ROOT) match {
+ case "true" | "false" => BooleanType
+ case _ => StringType
+ }
}
private def isTime(value: String): Boolean = {
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala
index 8be23cdaef00..1ca873d7da1a 100644
--- a/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala
+++ b/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala
@@ -7562,6 +7562,21 @@ object SQLConf {
.booleanConf
.createWithDefault(true)
+ val XML_SCHEMA_INFERENCE_INCREMENTAL_TYPECASTING =
+ buildConf("spark.sql.xml.schemaInference.incrementalTypeCasting.enabled")
+ .internal()
+ .doc(
+ "When true (default), XML schema inference refines the type inferred
for each field " +
+ "incrementally: each value is parsed starting from the type inferred
for that field so " +
+ "far, rather than probing every candidate type from scratch. This
avoids redundant parse " +
+ "attempts once a field has widened to a broader type. This matches the
incremental " +
+ "inference already used by the CSV datasource. Set to false to restore
the legacy " +
+ "behavior of inferring each value independently and merging
afterwards.")
+ .version("4.3.0")
+ .withBindingPolicy(ConfigBindingPolicy.SESSION)
+ .booleanConf
+ .createWithDefault(true)
+
val ASSUME_ANSI_FALSE_IF_NOT_PERSISTED =
buildConf("spark.sql.assumeAnsiFalseIfNotPersisted.enabled")
.internal()
@@ -8949,6 +8964,9 @@ class SQLConf extends Serializable with Logging with
SqlApiConf {
def xmlVariantRespectInferSchema: Boolean =
getConf(SQLConf.XML_VARIANT_RESPECT_INFER_SCHEMA)
+ def xmlSchemaInferenceIncrementalTypeCasting: Boolean =
+ getConf(SQLConf.XML_SCHEMA_INFERENCE_INCREMENTAL_TYPECASTING)
+
def coerceMergeNestedTypes: Boolean =
getConf(SQLConf.MERGE_INTO_NESTED_TYPE_COERCION_ENABLED)
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
new file mode 100644
index 000000000000..c78171d61565
--- /dev/null
+++
b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/xml/XmlInferSchemaTypeCastingSuite.scala
@@ -0,0 +1,165 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.spark.sql.catalyst.xml
+
+import org.apache.spark.SparkFunSuite
+import org.apache.spark.sql.catalyst.plans.SQLHelper
+import org.apache.spark.sql.types._
+
+/**
+ * Unit tests for [[XmlInferSchema]]'s incremental primitive-type inference.
`inferFrom(value,
+ * typeSoFar)` refines the type inferred for a field so far, entering the
`tryParse*` cascade at
+ * the parser matching `typeSoFar` and reconciling the result with
`compatibleType`. These tests
+ * check that refining from a non-null `typeSoFar` yields the same widened
type that inferring the
+ * value from scratch and then merging would produce -- i.e. the incremental
path is consistent
+ * with the batch path. The structure mirrors `CSVInferSchemaSuite`.
+ */
+class XmlInferSchemaTypeCastingSuite extends SparkFunSuite with SQLHelper {
+
+ private def newInferSchema(options: Map[String, String]): XmlInferSchema =
+ new XmlInferSchema(new XmlOptions(options, "UTC", "_corrupt_record"),
caseSensitive = false)
+
+ 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)
+ assert(inferSchema.inferFrom(null, NullType) == NullType)
+ assert(inferSchema.inferFrom("100000000000", NullType) == LongType)
+ // XML infers integral values as LongType (there is no IntegerType
narrowing).
+ assert(inferSchema.inferFrom("60", NullType) == LongType)
+ assert(inferSchema.inferFrom("3.5", NullType) == DoubleType)
+ assert(inferSchema.inferFrom("test", NullType) == StringType)
+ assert(inferSchema.inferFrom("2015-08-20 15:57:00", NullType) ==
TimestampType)
+ assert(inferSchema.inferFrom("True", NullType) == BooleanType)
+ assert(inferSchema.inferFrom("FAlSE", NullType) == BooleanType)
+
+ // A value beyond Long range is not preferred as decimal by default ->
Double.
+ val textValueOne = Long.MaxValue.toString + "0"
+ assert(inferSchema.inferFrom(textValueOne, NullType) == DoubleType)
+ }
+
+ test("String field types are inferred correctly from other types") {
+ val inferSchema = newInferSchema(Map("timestampFormat" -> "yyyy-MM-dd
HH:mm:ss"))
+
+ assert(inferSchema.inferFrom("1.0", LongType) == DoubleType)
+ assert(inferSchema.inferFrom("test", LongType) == StringType)
+ assert(inferSchema.inferFrom(null, DoubleType) == DoubleType)
+ assert(inferSchema.inferFrom("test", DoubleType) == StringType)
+ // A Long-so-far field seeing a timestamp string widens to String
(incompatible).
+ assert(inferSchema.inferFrom("2015-08-20 14:57:00", LongType) ==
StringType)
+ assert(inferSchema.inferFrom("2015-08-20 15:57:00", DoubleType) ==
StringType)
+ assert(inferSchema.inferFrom("True", LongType) == StringType)
+ assert(inferSchema.inferFrom("FALSE", TimestampType) == StringType)
+
+ val textValueOne = Long.MaxValue.toString + "0"
+ assert(inferSchema.inferFrom(textValueOne, LongType) == DoubleType)
+ }
+
+ test("Timestamp field types are inferred correctly via custom data format") {
+ var inferSchema = newInferSchema(Map("timestampFormat" -> "yyyy-mm"))
+ assert(inferSchema.inferFrom("2015-08", TimestampType) == TimestampType)
+
+ inferSchema = newInferSchema(Map("timestampFormat" -> "yyyy"))
+ assert(inferSchema.inferFrom("2015", TimestampType) == TimestampType)
+
+ val combined = Map(
+ "timestampFormat" -> "yyyy-MM-dd'T'HH:mm:ss",
+ "timestampNTZFormat" -> "yyyy-MM-dd HH:mm:ss")
+ inferSchema = newInferSchema(combined)
+ // Merging TimestampNTZ (so far) with a value that parses as Timestamp
yields Timestamp.
+ assert(inferSchema.inferFrom("2016-03-11T20:00:00", TimestampNTZType) ==
TimestampType)
+ }
+
+ test("Timestamp field types are inferred correctly from other types") {
+ val inferSchema = newInferSchema(Map.empty[String, String])
+
+ assert(inferSchema.inferFrom("2015-08-20 14", LongType) == StringType)
+ assert(inferSchema.inferFrom("2015-08-20 14:10", DoubleType) == StringType)
+ assert(inferSchema.inferFrom("2015-08 14:49:00", LongType) == StringType)
+ }
+
+ test("Boolean field types are inferred correctly from other types") {
+ val inferSchema = newInferSchema(Map.empty[String, String])
+
+ assert(inferSchema.inferFrom("Fale", LongType) == StringType)
+ assert(inferSchema.inferFrom("TRUEe", DoubleType) == StringType)
+ }
+
+ test("Empty and null values keep the type inferred so far") {
+ val inferSchema = newInferSchema(Map.empty[String, String])
+
+ // A genuinely empty/null value carries no type information, so
`typeSoFar` is preserved.
+ // (Unlike CSV, XML inference does not treat the `nullValue` option as
null here; a value
+ // equal to `nullValue` is inferred by its content, matching the
pre-existing behavior.)
+ assert(inferSchema.inferFrom("", NullType) == NullType)
+ assert(inferSchema.inferFrom("", LongType) == LongType)
+ assert(inferSchema.inferFrom(null, DoubleType) == DoubleType)
+ assert(inferSchema.inferFrom(null, TimestampType) == TimestampType)
+ }
+
+ test("Refining from a wider type never narrows and is consistent with fresh
inference") {
+ val inferSchema = newInferSchema(Map.empty[String, String])
+ // Once a field is Double, an integral value keeps it Double (does not
narrow to Long).
+ assert(inferSchema.inferFrom("2", DoubleType) == DoubleType)
+ // Refining Double with a Double value stays Double.
+ assert(inferSchema.inferFrom("1.5", DoubleType) == DoubleType)
+ // An incompatible value widens all the way to String.
+ assert(inferSchema.inferFrom("abc", DoubleType) == StringType)
+ }
+
+ test("Refining a numeric type-so-far matches fresh inference (SPARK-57802)")
{
+ // Refining a numeric `typeSoFar` re-enters the cascade at `tryParseLong`,
so an integer value
+ // infers as `Long` (not a narrow `Decimal`) exactly as from-scratch
inference would, and the
+ // merge with `typeSoFar` yields the same type as the legacy path.
Otherwise, under
+ // prefersDecimal, a `Decimal`-so-far field seeing "5" would infer
`Decimal(1,0)` and merge to
+ // a different (order-dependent) precision than the legacy
`Long`-then-merge result.
+ val prefersDecimal = newInferSchema(Map("prefersDecimal" -> "true"))
+ // "5" fresh -> Long; compatibleType(Decimal(5,2), Long) -> Decimal(22,2).
+ assert(prefersDecimal.inferFrom("5", DecimalType(5, 2)) == DecimalType(22,
2))
+ assert(prefersDecimal.inferFrom("5", DoubleType) == DoubleType)
+ // A fractional value still refines through the decimal/double parsers as
usual: "3.5" infers
+ // as Decimal(2,1) and merges with Decimal(5,2) to Decimal(5,2).
+ assert(prefersDecimal.inferFrom("3.5", DecimalType(5, 2)) ==
DecimalType(5, 2))
+ }
+
+ test("Refining a temporal type-so-far matches fresh inference
(SPARK-57802)") {
+ // Refining a temporal `typeSoFar` re-enters the cascade at the top of the
temporal sub-cascade
+ // (`tryParseTime`, flowing Time -> Date -> TimestampNTZ -> Timestamp), so
a date-only value
+ // infers as `Date` exactly as from-scratch inference would, and the merge
with `typeSoFar`
+ // widens through `findWiderDateTimeType` to the same type as the legacy
path. Otherwise, with a
+ // time-requiring `timestampFormat`, a `Timestamp`-so-far field seeing a
date-only value would
+ // fail `tryParseTimestamp`, fall through to `String`, and merge to
`String` -- an
+ // order-dependent divergence from the legacy `Date`-then-merge result.
+ val inferSchema = newInferSchema(Map("timestampFormat" ->
"yyyy-MM-dd'T'HH:mm:ss"))
+ // "2024-01-15" fresh -> Date; compatibleType(Timestamp, Date) ->
Timestamp (DATE adopts the
+ // LTZ family of the other side).
+ assert(inferSchema.inferFrom("2024-01-15", TimestampType) == TimestampType)
+ // Same for a TimestampNTZ-so-far field: DATE adopts the NTZ family, so
the merge stays NTZ.
+ assert(inferSchema.inferFrom("2024-01-15", TimestampNTZType) ==
TimestampNTZType)
+ // A Date-so-far field seeing a timestamp value widens to Timestamp.
+ assert(inferSchema.inferFrom("2024-01-15T10:00:00", DateType) ==
TimestampType)
+ }
+
+ test("date is inferred regardless of preferDate") {
+ Seq("true", "false").foreach { preferDate =>
+ val inferSchema = newInferSchema(Map("preferDate" -> preferDate))
+ assert(inferSchema.inferFrom("2024-01-15", NullType) == DateType,
+ s"expected DateType with preferDate=$preferDate")
+ }
+ }
+}
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 bd675bd62510..78755d531cd8 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
@@ -700,6 +700,90 @@ class XmlInferSchemaSuite
val dfTimeDate = readData(xmlTimeDate)
assert(dfTimeDate.schema.fields.head.dataType === StringType)
}
+
+ test("incremental inference widens the type across rows monotonically") {
+ // Long -> Double: once a value forces Double, a later Long value must not
narrow it back.
+ val longThenDouble = Seq(
+ """<ROW><v>1</v></ROW>""",
+ """<ROW><v>1.5</v></ROW>""",
+ """<ROW><v>2</v></ROW>""")
+ assert(readData(longThenDouble).schema.fields.head.dataType === DoubleType)
+
+ // Long -> String: an incompatible later value widens all the way to the
top type.
+ val longThenString = Seq(
+ """<ROW><v>1</v></ROW>""",
+ """<ROW><v>abc</v></ROW>""")
+ assert(readData(longThenString).schema.fields.head.dataType === StringType)
+
+ // The result is independent of row order (the merge is commutative).
+ val doubleThenLong = Seq(
+ """<ROW><v>1.5</v></ROW>""",
+ """<ROW><v>2</v></ROW>""")
+ assert(readData(doubleThenLong).schema.fields.head.dataType === DoubleType)
+ }
+
+ test("date is inferred regardless of preferDate") {
+ val xmlDate = Seq("""<ROW><d>2024-01-15</d></ROW>""")
+ // preferDate governs which date formatter is used, not whether date
inference is attempted.
+ Seq("true", "false").foreach { preferDate =>
+ val df = readData(xmlDate, Map("preferDate" -> preferDate))
+ assert(df.schema.fields.head.dataType === DateType,
+ s"expected DateType with preferDate=$preferDate")
+ }
+ }
+
+ test("incremental type casting yields the same schema as the legacy batch
path") {
+ // Incremental inference (the default) must produce the same inferred
schema as the legacy
+ // per-record path across a range of shapes: mixed numerics, nested
structs, repeated elements
+ // (arrays), attributes, value tags, cross-row widening to StringType, and
-- the case that
+ // makes incremental inference actually differ if the type-so-far is
threaded naively --
+ // prefersDecimal fields mixing a decimal and an integer, in both row
orders.
+ //
+ // All records are read as a SINGLE partition so that the incremental path
actually threads
+ // one record's type into the next within the partition; with the default
parallelism each
+ // record lands in its own partition and both paths trivially agree via
the final merge.
+ def readOnePartition(xml: Seq[String], options: Map[String, String]):
StructType = {
+ val ds = spark.createDataset(spark.sparkContext.parallelize(xml,
1))(Encoders.STRING)
+ spark.read.options(Map("rowTag" -> "ROW") ++ options).xml(ds).schema
+ }
+
+ val cases: Seq[(Seq[String], Map[String, String])] = Seq(
+ Seq("""<ROW><a>1</a><b>1.5</b></ROW>""",
"""<ROW><a>2.5</a><b>3</b></ROW>""") -> Map.empty,
+ Seq("""<ROW><n><x>1</x><y>t</y></n></ROW>""",
+ """<ROW><n><x>2.0</x><y>u</y></n></ROW>""") -> Map.empty,
+ Seq("""<ROW><arr>1</arr><arr>2</arr><arr>3.5</arr></ROW>""") ->
Map.empty,
+ Seq("""<ROW><e k="1">text</e></ROW>""", """<ROW><e
k="2.5">other</e></ROW>""") -> Map.empty,
+ Seq("""<ROW><v>2024-01-15</v></ROW>""",
+ """<ROW><v>2024-01-15T10:00:00</v></ROW>""") -> Map.empty,
+ Seq("""<ROW><v>1</v></ROW>""", """<ROW><v>not-a-number</v></ROW>""") ->
Map.empty,
+ // prefersDecimal: a decimal then an integer, and the reverse order.
+ Seq("""<ROW><a>123.45</a></ROW>""", """<ROW><a>5</a></ROW>""") ->
+ Map("prefersDecimal" -> "true"),
+ Seq("""<ROW><a>5</a></ROW>""", """<ROW><a>123.45</a></ROW>""") ->
+ Map("prefersDecimal" -> "true"),
+ // Temporal family with a time-requiring timestampFormat, in both row
orders. A date-only
+ // value must widen with a timestamp identically whether it is seen
first (so the field is
+ // Date when the timestamp arrives) or last (so the field is Timestamp
when the date arrives):
+ // entering the temporal cascade at its top keeps the date-only value
inferring as Date rather
+ // than falling through to String, so `findWiderDateTimeType` widens
Date + Timestamp to
+ // Timestamp on both the incremental and the legacy path.
+ Seq("""<ROW><v>2024-01-15T10:00:00</v></ROW>""",
"""<ROW><v>2024-01-15</v></ROW>""") ->
+ Map("timestampFormat" -> "yyyy-MM-dd'T'HH:mm:ss"),
+ Seq("""<ROW><v>2024-01-15</v></ROW>""",
"""<ROW><v>2024-01-15T10:00:00</v></ROW>""") ->
+ Map("timestampFormat" -> "yyyy-MM-dd'T'HH:mm:ss"))
+
+ cases.foreach { case (xml, options) =>
+ val incremental = withSQLConf(
+ SQLConf.XML_SCHEMA_INFERENCE_INCREMENTAL_TYPECASTING.key -> "true") {
+ readOnePartition(xml, options)
+ }
+ val batch = withSQLConf(
+ SQLConf.XML_SCHEMA_INFERENCE_INCREMENTAL_TYPECASTING.key -> "false") {
+ readOnePartition(xml, options)
+ }
+ assert(incremental === batch, s"incremental and batch schemas differ
for: $xml")
+ }
+ }
}
trait XmlSchemaInferenceCaseSensitivityTests extends QueryTest {
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]