This is an automated email from the ASF dual-hosted git repository.
MaxGekk pushed a commit to branch branch-4.x
in repository https://gitbox.apache.org/repos/asf/spark.git
The following commit(s) were added to refs/heads/branch-4.x by this push:
new 1ae9c3d56636 [SPARK-57458][SQL] Support nanosecond-precision timestamp
types in the XML datasource
1ae9c3d56636 is described below
commit 1ae9c3d56636ed3bd95c3470fb7c44d988957217
Author: Vinod KC <[email protected]>
AuthorDate: Tue Jun 30 11:46:17 2026 +0200
[SPARK-57458][SQL] Support nanosecond-precision timestamp types in the XML
datasource
### What changes were proposed in this pull request?
This PR adds support for nanosecond-precision timestamp types
(`TimestampLTZNanosType` and `TimestampNTZNanosType`) in the XML datasource,
covering:
- Writing: `StaxXmlGenerator` handles the two nano timestamp types by
calling the nanosecond-aware formatter methods (formatNanos,
formatWithoutTimeZoneNanos).
- Reading: `StaxXmlParser` routes nano timestamp types to the corresponding
`parseNanos` / `parseWithoutTimeZoneNanos` formatter methods in the
schema-directed path, and delegates to castTo in the type-coercion path.
- Schema inference: `XmlInferSchema` now infers `TimestampNTZNanosType(9)`
when a field value carries sub-microsecond fractional seconds (>6 digits) and
`TIMESTAMP_NANOS_TYPES_ENABLED` is on.
The compatibleType widening function is extended to merge two nano
timestamp types (taking the higher precision), downgrade
`TimestampNTZNanosType` + `TimestampNTZType` to `TimestampNTZType`, and fall
back to `TimestampType` for any other nano/non-nano combination. The
`StructType` and `ArrayType` recursive cases are moved into the
pre-TypeCoercion block so that nested fields also benefit from the
nano-widening logic.
### Why are the changes needed?
`xml` rejected nanos timestamp types in its datasource capability checks
and lacked the conversions to round-trip them, so these columns could not be
written or read through `xml`.
### Does this PR introduce _any_ user-facing change?
Yes. With `spark.sql.timestampNanosTypes.enabled` set to `true`:
- XML files can now be written and read back with `TimestampLTZNanosType`
and `TimestampNTZNanosType` columns without error.
- Schema inference promotes a timestamp field to `TimestampNTZNanosType(9)`
when its string value contains more than 6 fractional-second digits .
### How was this patch tested?
`XmlSuite`: Added two new inference tests — one verifying that a 9-digit
NTZ timestamp string is inferred as `TimestampNTZNanosType(9)`, and one
verifying that a mix of micro-precision and nano-precision NTZ rows in the same
file degrades to `TimestampNTZType`
Updated `FileBasedDataSourceSuite` and `XmlFunctionsSuite`
### Was this patch authored or co-authored using generative AI tooling?
Yes, Generated-by: Claude (Sonnet 4.6)
Closes #56854 from vinodkc/SPARK-57458.
Authored-by: Vinod KC <[email protected]>
Signed-off-by: Max Gekk <[email protected]>
(cherry picked from commit fe6bc958977826d65eedce08c5a68fee672a6440)
Signed-off-by: Max Gekk <[email protected]>
---
.../spark/sql/catalyst/xml/StaxXmlGenerator.scala | 6 +-
.../spark/sql/catalyst/xml/StaxXmlParser.scala | 5 ++
.../spark/sql/catalyst/xml/XmlInferSchema.scala | 67 ++++++++++++-----
.../execution/datasources/xml/XmlFileFormat.scala | 3 -
.../spark/sql/FileBasedDataSourceSuite.scala | 85 +++++++++++++++++++++-
.../org/apache/spark/sql/XmlFunctionsSuite.scala | 50 +++++++++----
.../sql/execution/datasources/xml/XmlSuite.scala | 68 +++++++++++++++++
7 files changed, 245 insertions(+), 39 deletions(-)
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/xml/StaxXmlGenerator.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/xml/StaxXmlGenerator.scala
index 6e381a2974c7..558cd8acf61c 100644
---
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/xml/StaxXmlGenerator.scala
+++
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/xml/StaxXmlGenerator.scala
@@ -32,7 +32,7 @@ import org.apache.spark.sql.catalyst.util.{ArrayData,
DateFormatter, DateTimeUti
import org.apache.spark.sql.catalyst.util.LegacyDateFormats.FAST_DATE_FORMAT
import org.apache.spark.sql.types._
import org.apache.spark.types.variant.VariantUtil
-import org.apache.spark.unsafe.types.{UTF8String, VariantVal}
+import org.apache.spark.unsafe.types.{TimestampNanosVal, UTF8String,
VariantVal}
class StaxXmlGenerator(
schema: DataType,
@@ -200,6 +200,10 @@ class StaxXmlGenerator(
gen.writeCharacters(timestampFormatter.format(v))
case (TimestampNTZType, v: Long) =>
gen.writeCharacters(timestampNTZFormatter.format(DateTimeUtils.microsToLocalDateTime(v)))
+ case (t: TimestampLTZNanosType, v: TimestampNanosVal) =>
+ gen.writeCharacters(timestampFormatter.formatNanos(v, t.precision))
+ case (t: TimestampNTZNanosType, v: TimestampNanosVal) =>
+ gen.writeCharacters(timestampNTZFormatter.formatWithoutTimeZoneNanos(v,
t.precision))
case (DateType, v: Int) =>
gen.writeCharacters(dateFormatter.format(v))
case (_: TimeType, v: Long) => gen.writeCharacters(timeFormatter.format(v))
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 593e6a3a97e1..f4eb443d95f5 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
@@ -601,6 +601,9 @@ class StaxXmlParser(
Decimal(decimalParser(datum), dt.precision, dt.scale)
case _: TimestampType => parseXmlTimestamp(datum, options)
case _: TimestampNTZType =>
timestampNTZFormatter.parseWithoutTimeZone(datum, false)
+ case t: TimestampLTZNanosType => timestampFormatter.parseNanos(datum,
t.precision)
+ case t: TimestampNTZNanosType =>
+ timestampNTZFormatter.parseWithoutTimeZoneNanos(datum, t.precision,
false)
case _: DateType => parseXmlDate(datum, options)
case _: TimeType => timeFormatter.parse(datum)
case _: StringType => UTF8String.fromString(datum)
@@ -652,6 +655,8 @@ class StaxXmlParser(
case DateType => castTo(value, DateType)
case TimestampType => castTo(value, TimestampType)
case TimestampNTZType => castTo(value, TimestampNTZType)
+ case t: TimestampLTZNanosType => castTo(value, t)
+ case t: TimestampNTZNanosType => castTo(value, t)
case _: TimeType => castTo(value, TimeType())
case FloatType => signSafeToFloat(value)
case ByteType => castTo(value, ByteType)
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 d6a686659589..7fbce121e624 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
@@ -542,6 +542,15 @@ class XmlInferSchema(private val options: XmlOptions,
private val caseSensitive:
if ((SQLConf.get.legacyTimeParserPolicy == LegacyBehaviorPolicy.LEGACY ||
timestampType == TimestampNTZType) &&
timestampNTZFormatter.parseWithoutTimeZoneOptional(field,
false).isDefined) {
+ if (SQLConf.get.timestampNanosTypesEnabled) {
+ // Prefer nanosecond type when the fractional seconds part has more
than 6 digits,
+ // indicating sub-microsecond precision that cannot be represented
by TimestampNTZType.
+ val nanosOpt =
+ timestampNTZFormatter.parseWithoutTimeZoneNanosOptional(field, 9,
false)
+ nanosOpt.filter(_.nanosWithinMicro != 0).foreach { _ =>
+ return Some(TimestampNTZNanosType(9))
+ }
+ }
return Some(timestampType)
}
} catch {
@@ -641,8 +650,45 @@ object XmlInferSchema {
(t1: DataType, t2: DataType): DataType = {
// TODO: Optimise this logic.
+ // AnyTimestampNanoType extends DatetimeType but is not covered by
findWiderDateTimeType;
+ // handle it first to avoid a MatchError inside
TypeCoercion.findTightestCommonType.
+ // StructType and ArrayType are also handled here so that compatibleType
is used recursively
+ // for nested field types, preserving the nano-timestamp downgrade logic
at all nesting levels.
+ // (TypeCoercion.findTightestCommonType handles same-structure
StructType/ArrayType via
+ // findTypeForComplex, which calls findWiderDateTimeType and would bypass
the custom logic.)
+ (t1, t2) match {
+ case (n1: TimestampNTZNanosType, n2: TimestampNTZNanosType) =>
+ return TimestampNTZNanosType(math.max(n1.precision, n2.precision))
+ case (n1: TimestampLTZNanosType, n2: TimestampLTZNanosType) =>
+ return TimestampLTZNanosType(math.max(n1.precision, n2.precision))
+ case (_: TimestampNTZNanosType, TimestampNTZType) |
+ (TimestampNTZType, _: TimestampNTZNanosType) =>
+ return TimestampNTZType
+ case (_: AnyTimestampNanoType, _: DatetimeType) |
+ (_: DatetimeType, _: AnyTimestampNanoType) =>
+ return TimestampType
+ case (StructType(fields1), StructType(fields2)) =>
+ val newFields = (fields1 ++ fields2)
+ // normalize field name and pair it with original field
+ .map(field => (normalize(field.name, caseSensitive), field))
+ .groupBy(_._1) // group by normalized field name
+ .map { case (_: String, fields: Array[(String, StructField)]) =>
+ val fieldTypes = fields.map(_._2)
+ val dataType = fieldTypes.map(_.dataType)
+ .reduce(compatibleType(caseSensitive, valueTag))
+ // we pick up the first field name that we've encountered for the
field
+ StructField(fields.head._2.name, dataType)
+ }
+ return StructType(newFields.toArray.sortBy(_.name))
+ case (ArrayType(elementType1, containsNull1), ArrayType(elementType2,
containsNull2)) =>
+ return ArrayType(
+ compatibleType(caseSensitive, valueTag)(elementType1, elementType2),
+ containsNull1 || containsNull2)
+ case _ =>
+ }
+
TypeCoercion.findTightestCommonType(t1, t2).getOrElse {
- // t1 or t2 is a StructType, ArrayType, or an unexpected type.
+ // t1 or t2 is an unexpected type combination (DecimalType variants,
valueTag structs, etc.)
(t1, t2) match {
// Double support larger range than fixed decimal, DecimalType.Maximum
should be enough
// in most case, also have better precision.
@@ -661,25 +707,6 @@ object XmlInferSchema {
case (TimestampNTZType, TimestampType) | (TimestampType,
TimestampNTZType) =>
TimestampType
- case (StructType(fields1), StructType(fields2)) =>
- val newFields = (fields1 ++ fields2)
- // normalize field name and pair it with original field
- .map(field => (normalize(field.name, caseSensitive), field))
- .groupBy(_._1) // group by normalized field name
- .map { case (_: String, fields: Array[(String, StructField)]) =>
- val fieldTypes = fields.map(_._2)
- val dataType = fieldTypes.map(_.dataType)
- .reduce(compatibleType(caseSensitive, valueTag))
- // we pick up the first field name that we've encountered for the
field
- StructField(fields.head._2.name, dataType)
- }
- StructType(newFields.toArray.sortBy(_.name))
-
- case (ArrayType(elementType1, containsNull1), ArrayType(elementType2,
containsNull2)) =>
- ArrayType(
- compatibleType(caseSensitive, valueTag)(
- elementType1, elementType2), containsNull1 || containsNull2)
-
// In XML datasource, since StructType can be compared with ArrayType.
// In this case, ArrayType wraps the StructType.
case (ArrayType(ty1, _), ty2) =>
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/xml/XmlFileFormat.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/xml/XmlFileFormat.scala
index ff4e57570a1d..da81ff327c74 100644
---
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/xml/XmlFileFormat.scala
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/xml/XmlFileFormat.scala
@@ -149,9 +149,6 @@ case class XmlFileFormat() extends TextBasedFileFormat with
DataSourceRegister {
case _: GeometryType | _: GeographyType => false
- // Nanosecond-capable timestamps are not yet supported by this datasource.
- case _: AnyTimestampNanoType => false
-
case _: AtomicType => true
case st: StructType => st.forall { f => supportDataType(f.dataType) }
diff --git
a/sql/core/src/test/scala/org/apache/spark/sql/FileBasedDataSourceSuite.scala
b/sql/core/src/test/scala/org/apache/spark/sql/FileBasedDataSourceSuite.scala
index 11f75d3d9a7a..16d2bc0d3082 100644
---
a/sql/core/src/test/scala/org/apache/spark/sql/FileBasedDataSourceSuite.scala
+++
b/sql/core/src/test/scala/org/apache/spark/sql/FileBasedDataSourceSuite.scala
@@ -1341,7 +1341,7 @@ class FileBasedDataSourceSuite extends SharedSparkSession
test("SPARK-57166: nanosecond timestamp types are not supported in selected
file data sources") {
// Parquet and ORC support nanosecond-capable timestamps, while these
formats still reject them.
- val unsupportedDataSources = Seq("json", "xml")
+ val unsupportedDataSources = Seq("json")
val nanosTypes = Seq(TimestampNTZNanosType(9), TimestampLTZNanosType(9))
withSQLConf(SQLConf.TIMESTAMP_NANOS_TYPES_ENABLED.key -> "true") {
// Test both v1 and v2 data sources.
@@ -1635,6 +1635,89 @@ class FileBasedDataSourceSuite extends SharedSparkSession
}
}
}
+
+ test("SPARK-57458: XML supports nanosecond timestamp types") {
+ withSQLConf(SQLConf.TIMESTAMP_NANOS_TYPES_ENABLED.key -> "true") {
+ foreachNanosPrecision { precision =>
+ Seq(TimestampNTZNanosType(precision),
TimestampLTZNanosType(precision)).foreach {
+ nanosType =>
+ withTempDir { dir =>
+ val wallClock = LocalDateTime.of(1970, 1, 1, 0, 20, 34,
567890123)
+ val (value, fmtKey, fmtVal) = nanosType match {
+ case _: TimestampNTZNanosType =>
+ (wallClock.asInstanceOf[Any],
+ "timestampNTZFormat", "yyyy-MM-dd'T'HH:mm:ss.SSSSSSSSS")
+ case _: TimestampLTZNanosType =>
+ (wallClock.toInstant(ZoneOffset.UTC).asInstanceOf[Any],
+ "timestampFormat", "yyyy-MM-dd'T'HH:mm:ss.SSSSSSSSSXXX")
+ }
+ val df = spark.createDataFrame(
+ spark.sparkContext.parallelize(Seq(Row(value))),
+ new StructType().add("ts", nanosType))
+ val path = new File(dir,
s"xml_nanos_${nanosType.typeName}").getCanonicalPath
+ df.write.format("xml").option("rowTag", "row")
+ .option(fmtKey, fmtVal).mode("overwrite").save(path)
+ val readBack = spark.read.schema(new StructType().add("ts",
nanosType))
+ .format("xml").option("rowTag", "row").option(fmtKey,
fmtVal).load(path)
+ checkAnswer(readBack, df)
+ }
+ }
+ }
+ }
+ }
+
+ test("SPARK-57458: XML rejects nanosecond timestamps under the LEGACY time
parser policy") {
+ // The legacy timestamp formatter cannot represent sub-microsecond digits,
so the nanos
+ // formatter methods raise TIMESTAMP_NANOS_WITH_LEGACY_TIME_PARSER. Only
the LTZ formatter
+ // is legacy under this policy (the NTZ formatter always uses the ISO-8601
path), so this
+ // covers TimestampLTZNanosType. XML is v1-only so there is no v1/v2
branching to test.
+ def rootNanosError(e: Throwable): SparkUnsupportedOperationException = {
+ var cause: Throwable = e
+ while (cause != null &&
!cause.isInstanceOf[SparkUnsupportedOperationException]) {
+ cause = cause.getCause
+ }
+ assert(cause != null,
+ s"Expected TIMESTAMP_NANOS_WITH_LEGACY_TIME_PARSER, but got:
${e.getMessage}")
+ cause.asInstanceOf[SparkUnsupportedOperationException]
+ }
+
+ withSQLConf(
+ SQLConf.TIMESTAMP_NANOS_TYPES_ENABLED.key -> "true",
+ SQLConf.LEGACY_TIME_PARSER_POLICY.key -> "LEGACY") {
+ val nanosType = TimestampLTZNanosType(9)
+ val schema = new StructType().add("ts", nanosType)
+ val expectedParameters =
+ Map("config" -> ("\"" + SQLConf.LEGACY_TIME_PARSER_POLICY.key + "\""))
+ withTempDir { dir =>
+ // Write path.
+ val df = spark.createDataFrame(
+ spark.sparkContext.parallelize(
+ Seq(Row(LocalDateTime.of(2020, 1, 1, 0, 0, 0,
1).toInstant(ZoneOffset.UTC)))),
+ schema)
+ val writeDir = new File(dir, "write").getCanonicalPath
+ checkError(
+ exception = rootNanosError(intercept[SparkException] {
+ df.write.format("xml").option("rowTag",
"row").mode("overwrite").save(writeDir)
+ }),
+ condition =
"UNSUPPORTED_FEATURE.TIMESTAMP_NANOS_WITH_LEGACY_TIME_PARSER",
+ parameters = expectedParameters)
+
+ // Read path: write a benign file first so schema-driven parsing is
what fails. Use
+ // FAILFAST so the unsupported-feature error surfaces instead of being
swallowed as a
+ // null record by the permissive bad-record handling.
+ val readDir = new File(dir, "read").getCanonicalPath
+ Seq("a").toDF("ts").write.format("xml").option("rowTag", "row")
+ .mode("overwrite").save(readDir)
+ checkError(
+ exception = rootNanosError(intercept[SparkException] {
+ spark.read.schema(schema).option("mode",
"FAILFAST").option("rowTag", "row")
+ .format("xml").load(readDir).collect()
+ }),
+ condition =
"UNSUPPORTED_FEATURE.TIMESTAMP_NANOS_WITH_LEGACY_TIME_PARSER",
+ parameters = expectedParameters)
+ }
+ }
+ }
}
object TestingUDT {
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 ef875064e0ff..dd6bc024dd8e 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
@@ -18,11 +18,13 @@
package org.apache.spark.sql
import java.text.SimpleDateFormat
+import java.time.{LocalDateTime, ZoneOffset}
import java.util.Locale
import scala.jdk.CollectionConverters._
import org.apache.spark.SparkException
+import org.apache.spark.sql.catalyst.util.TimestampNanosTestUtils
import
org.apache.spark.sql.catalyst.util.TimestampNanosTestUtils.foreachNanosPrecision
import org.apache.spark.sql.execution.WholeStageCodegenExec
import org.apache.spark.sql.functions._
@@ -45,25 +47,45 @@ class XmlFunctionsSuite extends SharedSparkSession {
test("SPARK-57164: from_xml with a nanos timestamp DDL schema string") {
val df =
Seq("""<ROW><c>2020-01-01T00:00:00.123456789</c></ROW>""").toDF("value")
// FAILFAST so the value-converter rejection propagates instead of
becoming a corrupt record.
+ // Pin the session timezone to UTC so LTZ values are predictable without a
zone in the string.
val options = Map("mode" -> "FAILFAST").asJava
- withSQLConf(SQLConf.TIMESTAMP_NANOS_TYPES_ENABLED.key -> "true") {
+ withSQLConf(
+ SQLConf.TIMESTAMP_NANOS_TYPES_ENABLED.key -> "true",
+ SQLConf.SESSION_LOCAL_TIMEZONE.key -> "UTC") {
foreachNanosPrecision { p =>
+ val truncator = TimestampNanosTestUtils.nanoOfSecTruncator(p)
+ val truncNanos = truncator(123456789)
+ val expectedNTZ = LocalDateTime.of(2020, 1, 1, 0, 0, 0, truncNanos)
+ val expectedLTZ = expectedNTZ.toInstant(ZoneOffset.UTC)
Seq(
- s"TIMESTAMP_NTZ($p)" -> TimestampNTZNanosType(p),
- s"TIMESTAMP_LTZ($p)" -> TimestampLTZNanosType(p),
- s"TIMESTAMP($p) WITHOUT TIME ZONE" -> TimestampNTZNanosType(p),
- s"TIMESTAMP($p) WITH LOCAL TIME ZONE" ->
TimestampLTZNanosType(p)).foreach {
- case (spelling, expected) =>
+ s"TIMESTAMP_NTZ($p)" -> (TimestampNTZNanosType(p),
expectedNTZ.asInstanceOf[Any]),
+ s"TIMESTAMP_LTZ($p)" -> (TimestampLTZNanosType(p),
expectedLTZ.asInstanceOf[Any]),
+ s"TIMESTAMP($p) WITHOUT TIME ZONE" ->
+ (TimestampNTZNanosType(p), expectedNTZ.asInstanceOf[Any]),
+ s"TIMESTAMP($p) WITH LOCAL TIME ZONE" ->
+ (TimestampLTZNanosType(p), expectedLTZ.asInstanceOf[Any])).foreach
{
+ case (spelling, (expectedType, expectedVal)) =>
val parsed = df.select(from_xml($"value", s"c $spelling",
options).as("v"))
// The schema string resolves to the nanos type ...
-
assert(parsed.schema("v").dataType.asInstanceOf[StructType]("c").dataType ===
expected)
- // ... but the XML datasource does not support nanosecond
timestamps yet, so the
- // value converter rejects it at execution (surfaced as a
malformed record in
- // FAILFAST mode).
- checkError(
- exception = intercept[SparkException](parsed.collect()),
- condition = "MALFORMED_RECORD_IN_PARSING.WITHOUT_SUGGESTION",
- parameters = Map("badRecord" -> "[null]", "failFastMode" ->
"FAILFAST"))
+ val parsedType =
parsed.schema("v").dataType.asInstanceOf[StructType]("c").dataType
+ assert(parsedType === expectedType)
+ // ... the XML datasource parses the value and round-trips to the
expected value.
+ checkAnswer(parsed, Row(Row(expectedVal)) :: Nil)
+ }
+ }
+ }
+ }
+
+ test("SPARK-57458: from_xml rejects zoned input for NTZ nanos columns") {
+ // A string with an explicit zone offset must be rejected for
TIMESTAMP_NTZ(p) because
+ // the NTZ parse path uses allowTimeZone=false, matching the micro NTZ and
CSV behaviour.
+ val df =
Seq("""<ROW><c>2020-01-01T00:00:00.123456789+05:00</c></ROW>""").toDF("value")
+ val options = Map("mode" -> "FAILFAST").asJava
+ withSQLConf(SQLConf.TIMESTAMP_NANOS_TYPES_ENABLED.key -> "true") {
+ foreachNanosPrecision { p =>
+ val parsed = df.select(from_xml($"value", s"c TIMESTAMP_NTZ($p)",
options).as("v"))
+ intercept[SparkException] {
+ parsed.collect()
}
}
}
diff --git
a/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/xml/XmlSuite.scala
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/xml/XmlSuite.scala
index a1aae1481f81..8a8ab4de3a8f 100644
---
a/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/xml/XmlSuite.scala
+++
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/xml/XmlSuite.scala
@@ -3694,6 +3694,74 @@ class XmlSuite
assert(XmlOptions.isValidOption("encoding"))
assert(XmlOptions.isValidOption("charset"))
}
+
+ // Full-precision format pattern for nanosecond NTZ timestamp
schema-inference tests.
+ private val ntzNanosFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSSSSS"
+
+ test("SPARK-57458: XML infers nanosecond NTZ timestamps from sub-microsecond
fractional digits") {
+ withSQLConf(
+ SQLConf.TIMESTAMP_NANOS_TYPES_ENABLED.key -> "true",
+ SQLConf.TIMESTAMP_TYPE.key -> "TIMESTAMP_NTZ") {
+ // Write a nanosecond DataFrame with a 9-digit format so the XML values
carry 9 fractional
+ // digits; then read without a schema so the type is inferred from the
string values.
+ val wallClock = LocalDateTime.of(2025, 6, 15, 12, 30, 45, 123456789)
+ val ntzType = TimestampNTZNanosType(9)
+ val inputDf = spark.createDataFrame(
+ spark.sparkContext.parallelize(Seq(Row(wallClock))),
+ new StructType().add("ts", ntzType))
+ withTempPath { dir =>
+ val path = dir.getCanonicalPath
+ inputDf.write.format("xml").option("rowTag", "ROW")
+ .option("timestampNTZFormat",
ntzNanosFormat).mode("overwrite").save(path)
+ val df = spark.read.format("xml").option("rowTag", "ROW").load(path)
+ assert(df.schema("ts").dataType === TimestampNTZNanosType(9),
+ s"Expected TimestampNTZNanosType(9), got
${df.schema("ts").dataType}")
+ }
+ }
+ }
+
+ test("SPARK-57458: XML inferred type is TimestampNTZType for mixed
micro/nano NTZ rows") {
+ // When some rows have >6 fractional digits (nano) and others have <=6
(micro), the inferred
+ // type must be TimestampNTZType (not TimestampType / LTZ), because all
values are zone-free.
+ withSQLConf(
+ SQLConf.TIMESTAMP_NANOS_TYPES_ENABLED.key -> "true",
+ SQLConf.TIMESTAMP_TYPE.key -> "TIMESTAMP_NTZ") {
+ val xmlContent =
+ """<root><row><ts>2025-06-15T12:30:45.123456789</ts></row>
+ |<row><ts>2025-06-15T12:30:45.123456</ts></row></root>""".stripMargin
+ withTempDir { dir =>
+ val path = new File(dir, "mixed.xml").getCanonicalPath
+ Files.write(Paths.get(path),
+ xmlContent.getBytes(StandardCharsets.UTF_8))
+ val df = spark.read.format("xml").option("rowTag", "row")
+ .option("rootTag", "root").load(path)
+ assert(df.schema("ts").dataType === TimestampNTZType,
+ s"Expected TimestampNTZType, got ${df.schema("ts").dataType}")
+ }
+ }
+ }
+
+ test("SPARK-57458: nano timestamp + non-datetime field widens to StringType
during inference") {
+ // When a field is a nano-precision timestamp in some rows and a
non-datetime value in others,
+ // inference must fall back to StringType (matching the micro-precision
path) rather than
+ // widening to TimestampType and then failing at read time.
+ withSQLConf(
+ SQLConf.TIMESTAMP_NANOS_TYPES_ENABLED.key -> "true",
+ SQLConf.TIMESTAMP_TYPE.key -> "TIMESTAMP_NTZ") {
+ val xmlContent =
+ """<root><row><ts>2025-06-15T12:30:45.123456789</ts></row>
+ |<row><ts>not-a-timestamp</ts></row></root>""".stripMargin
+ withTempDir { dir =>
+ val path = new File(dir, "nano_nondatetime.xml").getCanonicalPath
+ Files.write(Paths.get(path),
xmlContent.getBytes(StandardCharsets.UTF_8))
+ val df = spark.read.format("xml").option("rowTag", "row")
+ .option("rootTag", "root").load(path)
+ assert(df.schema("ts").dataType === StringType,
+ s"Expected StringType for nano + non-datetime, got
${df.schema("ts").dataType}")
+ }
+ }
+ }
+
}
class XmlSuiteWithLegacyParser extends XmlSuite {
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]