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 6abed0faac3f [SPARK-57457][SQL] Support nanosecond-precision timestamp 
types in the CSV datasource (v1 and v2)
6abed0faac3f is described below

commit 6abed0faac3fa2414455aaf9f21b2b8744bbcd24
Author: Vinod KC <[email protected]>
AuthorDate: Sat Jun 27 11:55:20 2026 +0200

    [SPARK-57457][SQL] Support nanosecond-precision timestamp types in the CSV 
datasource (v1 and v2)
    
    ### What changes were proposed in this pull request?
    
    This PR adds nanosecond-precision timestamp support (`TIMESTAMP_NTZ(p)` and 
`TIMESTAMP_LTZ(p)`) to the `CSV` datasource, for both the v1 (`CSVFileFormat`) 
and v2 (`CSVTable`) paths.
    
    Specifically:
    - Parser (`UnivocityParser`): adds `TimestampNTZNanosType` and 
`TimestampLTZNanosType` cases that delegate to the existing 
`parseWithoutTimeZoneNanos` / `parseNanos` formatter methods.
    - Generator (`UnivocityGenerator`): adds the corresponding write-path cases 
that delegate to `formatWithoutTimeZoneNanos` / f`ormatNanos`.
    
    ### Why are the changes needed?
    
    `CSV` 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 CSV.
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes. Users can write and read `TimestampNTZNanosType(p)` / 
`TimestampLTZNanosType(p)` (p in 7..9) with CSV
    
    ### How was this patch tested?
    
    - `CsvFunctionsSuite`   — updated the existing from_csv nanosecond 
timestamp test: the test now asserts successful parsing and correct truncated 
value rather than expecting an UNSUPPORTED_DATATYPE exception.
    
    - `FileBasedDataSourceSuite`  — new end-to-end round-trip test covering 
both v1 and v2 source paths, precisions (7–9), and both TimestampNTZNanosType 
and TimestampLTZNanosType, verifying that a DataFrame written to CSV and read 
back with a matching schema produces identical results.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Yes, Generated-by: Claude Code (Sonnet 4.6) was used to assist with this 
patch.
    
    Closes #56818 from vinodkc/spark-57457-nanosecond-csv.
    
    Authored-by: Vinod KC <[email protected]>
    Signed-off-by: Max Gekk <[email protected]>
    (cherry picked from commit ab78cb510b3cdae05fa1dae4645ff7d696de08ec)
    Signed-off-by: Max Gekk <[email protected]>
---
 .../sql/catalyst/csv/UnivocityGenerator.scala      |  9 +++++
 .../spark/sql/catalyst/csv/UnivocityParser.scala   | 10 +++++
 .../execution/datasources/csv/CSVFileFormat.scala  |  3 --
 .../execution/datasources/v2/csv/CSVTable.scala    |  5 +--
 .../org/apache/spark/sql/CsvFunctionsSuite.scala   | 23 +++++++----
 .../spark/sql/FileBasedDataSourceSuite.scala       | 44 +++++++++++++++++++++-
 6 files changed, 78 insertions(+), 16 deletions(-)

diff --git 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/csv/UnivocityGenerator.scala
 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/csv/UnivocityGenerator.scala
index ce0f9aaaa61f..1b5c32459942 100644
--- 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/csv/UnivocityGenerator.scala
+++ 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/csv/UnivocityGenerator.scala
@@ -85,6 +85,15 @@ class UnivocityGenerator(
       (getter, ordinal) =>
         
timestampNTZFormatter.format(DateTimeUtils.microsToLocalDateTime(getter.getLong(ordinal)))
 
+    case t: TimestampNTZNanosType =>
+      (getter, ordinal) =>
+        timestampNTZFormatter.formatWithoutTimeZoneNanos(
+          getter.getTimestampNTZNanos(ordinal), t.precision)
+
+    case t: TimestampLTZNanosType =>
+      (getter, ordinal) =>
+        timestampFormatter.formatNanos(getter.getTimestampLTZNanos(ordinal), 
t.precision)
+
     case _: TimeType => (getter, ordinal) => 
timeFormatter.format(getter.getLong(ordinal))
 
     case YearMonthIntervalType(start, end) =>
diff --git 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/csv/UnivocityParser.scala
 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/csv/UnivocityParser.scala
index a028f77495a4..fa6bd19064f0 100644
--- 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/csv/UnivocityParser.scala
+++ 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/csv/UnivocityParser.scala
@@ -263,6 +263,16 @@ class UnivocityParser(
         timestampNTZFormatter.parseWithoutTimeZone(datum, false)
       }
 
+    case t: TimestampNTZNanosType => (d: String) =>
+      nullSafeDatum(d, name, nullable, options) { datum =>
+        timestampNTZFormatter.parseWithoutTimeZoneNanos(datum, t.precision, 
false)
+      }
+
+    case t: TimestampLTZNanosType => (d: String) =>
+      nullSafeDatum(d, name, nullable, options) { datum =>
+        timestampFormatter.parseNanos(datum, t.precision)
+      }
+
     case _: TimeType => (d: String) =>
       nullSafeDatum(d, name, nullable, options) { datum =>
         timeFormatter.parse(datum)
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/csv/CSVFileFormat.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/csv/CSVFileFormat.scala
index 5cc71d3d5cfb..ab7de0ffadb6 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/csv/CSVFileFormat.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/csv/CSVFileFormat.scala
@@ -170,9 +170,6 @@ case class CSVFileFormat() 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 udt: UserDefinedType[_] => supportDataType(udt.sqlType)
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/csv/CSVTable.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/csv/CSVTable.scala
index 8fdaad44376f..184eb41b6c49 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/csv/CSVTable.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/csv/CSVTable.scala
@@ -26,7 +26,7 @@ import 
org.apache.spark.sql.connector.write.{LogicalWriteInfo, Write, WriteBuild
 import org.apache.spark.sql.execution.datasources.FileFormat
 import org.apache.spark.sql.execution.datasources.csv.CSVDataSource
 import org.apache.spark.sql.execution.datasources.v2.FileTable
-import org.apache.spark.sql.types.{AnyTimestampNanoType, AtomicType, DataType, 
GeographyType, GeometryType, StructType, UserDefinedType}
+import org.apache.spark.sql.types.{AtomicType, DataType, GeographyType, 
GeometryType, StructType, UserDefinedType}
 import org.apache.spark.sql.util.CaseInsensitiveStringMap
 
 case class CSVTable(
@@ -63,9 +63,6 @@ case class CSVTable(
   override def supportsDataType(dataType: DataType): Boolean = dataType match {
     case _: GeometryType | _: GeographyType => false
 
-    // Nanosecond-capable timestamps are not yet supported by this datasource.
-    case _: AnyTimestampNanoType => false
-
     case _: AtomicType => true
 
     case udt: UserDefinedType[_] => supportsDataType(udt.sqlType)
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/CsvFunctionsSuite.scala 
b/sql/core/src/test/scala/org/apache/spark/sql/CsvFunctionsSuite.scala
index 32802a7b7282..455434b4de01 100644
--- a/sql/core/src/test/scala/org/apache/spark/sql/CsvFunctionsSuite.scala
+++ b/sql/core/src/test/scala/org/apache/spark/sql/CsvFunctionsSuite.scala
@@ -19,13 +19,14 @@ package org.apache.spark.sql
 
 import java.nio.charset.StandardCharsets
 import java.text.SimpleDateFormat
-import java.time.{Duration, LocalDateTime, Period}
+import java.time.{Duration, LocalDateTime, Period, ZoneOffset}
 import java.util.Locale
 
 import scala.jdk.CollectionConverters._
 
 import org.apache.spark.{SparkException, SparkRuntimeException,
   SparkUnsupportedOperationException, SparkUpgradeException}
+import org.apache.spark.sql.catalyst.util.TimestampNanosTestUtils
 import 
org.apache.spark.sql.catalyst.util.TimestampNanosTestUtils.foreachNanosPrecision
 import org.apache.spark.sql.errors.DataTypeErrors.toSQLType
 import org.apache.spark.sql.functions._
@@ -50,8 +51,12 @@ class CsvFunctionsSuite extends SharedSparkSession {
 
   test("SPARK-57164: from_csv with a nanos timestamp DDL schema string") {
     val df = Seq("2020-01-01T00:00:00.123456789").toDF("value")
-    withSQLConf(SQLConf.TIMESTAMP_NANOS_TYPES_ENABLED.key -> "true") {
+    // Fix the session timezone so the TIMESTAMP_LTZ expected value is 
deterministic.
+    withSQLConf(
+        SQLConf.TIMESTAMP_NANOS_TYPES_ENABLED.key -> "true",
+        SQLConf.SESSION_LOCAL_TIMEZONE.key -> "UTC") {
       foreachNanosPrecision { p =>
+        val nano = TimestampNanosTestUtils.nanoOfSecTruncator(p)(123456789)
         Seq(
           s"TIMESTAMP_NTZ($p)" -> TimestampNTZNanosType(p),
           s"TIMESTAMP_LTZ($p)" -> TimestampLTZNanosType(p),
@@ -62,12 +67,14 @@ class CsvFunctionsSuite extends SharedSparkSession {
               from_csv($"value", lit(s"c $spelling"), Map.empty[String, 
String].asJava).as("v"))
             // The schema string resolves to the nanos type ...
             
assert(parsed.schema("v").dataType.asInstanceOf[StructType]("c").dataType === 
expected)
-            // ... but the CSV datasource does not support nanosecond 
timestamps yet, so the
-            // value converter rejects it at execution.
-            checkError(
-              exception = 
intercept[SparkUnsupportedOperationException](parsed.collect()),
-              condition = "UNSUPPORTED_DATATYPE",
-              parameters = Map("typeName" -> toSQLType(expected)))
+            // ... and the CSV datasource correctly parses the nanosecond 
timestamp, truncating
+            // sub-precision digits toward zero.
+            val expectedValue = expected match {
+              case _: TimestampNTZNanosType => LocalDateTime.of(2020, 1, 1, 0, 
0, 0, nano)
+              case _: TimestampLTZNanosType =>
+                LocalDateTime.of(2020, 1, 1, 0, 0, 0, 
nano).toInstant(ZoneOffset.UTC)
+            }
+            checkAnswer(parsed, Row(Row(expectedValue)))
         }
       }
     }
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 34cc2088c955..11f75d3d9a7a 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", "csv", "xml")
+    val unsupportedDataSources = Seq("json", "xml")
     val nanosTypes = Seq(TimestampNTZNanosType(9), TimestampLTZNanosType(9))
     withSQLConf(SQLConf.TIMESTAMP_NANOS_TYPES_ENABLED.key -> "true") {
       // Test both v1 and v2 data sources.
@@ -1431,6 +1431,48 @@ class FileBasedDataSourceSuite extends SharedSparkSession
     }
   }
 
+  test("SPARK-57457: CSV supports nanosecond timestamp types in v1 and v2") {
+    withSQLConf(SQLConf.TIMESTAMP_NANOS_TYPES_ENABLED.key -> "true") {
+      Seq(true, false).foreach { useV1 =>
+        val useV1List = if (useV1) "csv" else ""
+        withSQLConf(SQLConf.USE_V1_SOURCE_LIST.key -> useV1List) {
+          foreachNanosPrecision { precision =>
+            // CSV is text-based: the format string must carry enough 
fractional-second digits to
+            // represent the full precision. Use exactly `precision` 
S-characters so the emitted
+            // string is compact and unambiguously round-trips at the given 
precision.
+            val fracPat = "S" * precision
+            Seq(TimestampNTZNanosType(precision), 
TimestampLTZNanosType(precision)).foreach {
+              nanosType =>
+                withTempDir { dir =>
+                  val wallClock = LocalDateTime.of(1970, 1, 1, 0, 20, 34, 
567890123)
+                  val value: Any = nanosType match {
+                    case _: TimestampNTZNanosType => wallClock
+                    case _: TimestampLTZNanosType => 
wallClock.toInstant(ZoneOffset.UTC)
+                  }
+                  val df = spark.createDataFrame(
+                    spark.sparkContext.parallelize(Seq(Row(value))),
+                    new StructType().add("ts", nanosType))
+                  val path = new File(dir, 
s"csv_nanos_${nanosType.typeName}").getCanonicalPath
+                  val (fmtKey, fmtVal) = nanosType match {
+                    case _: TimestampNTZNanosType =>
+                      ("timestampNTZFormat", s"yyyy-MM-dd'T'HH:mm:ss.$fracPat")
+                    case _: TimestampLTZNanosType =>
+                      ("timestampFormat", 
s"yyyy-MM-dd'T'HH:mm:ss.${fracPat}XXX")
+                  }
+                  df.write.format("csv").option(fmtKey, 
fmtVal).mode("overwrite").save(path)
+                  val readBack = spark.read
+                    .schema(new StructType().add("ts", nanosType))
+                    .option(fmtKey, fmtVal)
+                    .format("csv").load(path)
+                  checkAnswer(readBack, df)
+                }
+            }
+          }
+        }
+      }
+    }
+  }
+
   // Asserts the ignoredPathSegmentRegex contract for `format`: the default 
regex hides the
   // '_'-prefixed file; a never-matching per-read option or session conf each 
surface it; the
   // option overrides the conf.


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to